mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
194 Commits
v0.2.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb8506d1f | ||
|
|
ab24070075 | ||
|
|
fab975a059 | ||
|
|
504b687f89 | ||
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 | ||
|
|
76e336a1be | ||
|
|
f0d7eeb98c | ||
|
|
f91a984348 | ||
|
|
cbbf6c1684 | ||
|
|
89c2fac3c6 | ||
|
|
f8dd0fc2e0 | ||
|
|
b55acfc60f | ||
|
|
a6fdcb469c | ||
|
|
5bd317205c | ||
|
|
e59c94812b | ||
|
|
1c8fabd293 | ||
|
|
717f195f0c | ||
|
|
869edcbf11 | ||
|
|
c596e058cd | ||
|
|
fa1198c2be | ||
|
|
1f253d92e2 | ||
|
|
43cf858c37 | ||
|
|
5d397754bd | ||
|
|
2acb10fce9 | ||
|
|
e1b1ca569d | ||
|
|
b97d6d13f3 | ||
|
|
80c8a08393 | ||
|
|
1d1132de9c | ||
|
|
1a1ff8828f | ||
|
|
bb6231d556 | ||
|
|
07aa48cd6e | ||
|
|
392095c1b2 | ||
|
|
fe28850fad | ||
|
|
72c7d3fe3e | ||
|
|
e859d2df12 | ||
|
|
c9fb31f709 | ||
|
|
51ab86bab6 | ||
|
|
65633004b2 | ||
|
|
8409e237ed | ||
|
|
be52347d67 | ||
|
|
457679d45a | ||
|
|
830c9b62c4 | ||
|
|
36f38245ca | ||
|
|
782b341b1a | ||
|
|
7ef3376b62 | ||
|
|
bb3f5c5a2d | ||
|
|
a5942ef2de | ||
|
|
73e290bb57 | ||
|
|
e22c85373c | ||
|
|
cf36b2cc40 | ||
|
|
99440201bd | ||
|
|
19565f6605 | ||
|
|
055db7454d | ||
|
|
7250009ab7 | ||
|
|
21404fbdec | ||
|
|
21597bdc1b | ||
|
|
967edab497 | ||
|
|
b218267c3c | ||
|
|
5170833961 | ||
|
|
f4136c6d3e | ||
|
|
d98c5e77f7 | ||
|
|
fbbdbdc571 | ||
|
|
7e97ab4a9c | ||
|
|
0cb9ecf44b | ||
|
|
afcd972dd0 | ||
|
|
48b14d27a5 | ||
|
|
609f81bc11 | ||
|
|
779a56ed36 | ||
|
|
7bd4afe848 | ||
|
|
fc10c79e26 | ||
|
|
08c522c986 | ||
|
|
4b5551d61c | ||
|
|
b02acb882c | ||
|
|
13bce05630 | ||
|
|
17c12e2e1a | ||
|
|
c469ac124a | ||
|
|
09cfdecbbf | ||
|
|
1c95f8c37e | ||
|
|
12612f6646 | ||
|
|
a290f74705 | ||
|
|
b73592ccac | ||
|
|
8a8f2baf80 | ||
|
|
a3438f96f8 | ||
|
|
eb83e2bb45 | ||
|
|
2bffd6e71e | ||
|
|
1ffa2f821a | ||
|
|
fd0deea1c1 | ||
|
|
e8fb9bda6c | ||
|
|
5e47cff58e | ||
|
|
8e063f0dfc | ||
|
|
8cf70d10bf | ||
|
|
31c8dc3f05 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 | ||
|
|
89a022e853 | ||
|
|
14b25d4266 | ||
|
|
6cddc657cd | ||
|
|
14acb97662 | ||
|
|
4a4556f499 | ||
|
|
21d9bdc644 | ||
|
|
97cfcf8306 | ||
|
|
9603397aed | ||
|
|
309672c8e5 | ||
|
|
2ed1f4f353 | ||
|
|
cb6d2f1459 | ||
|
|
cf9dfcf947 | ||
|
|
7d10791bad | ||
|
|
2cc1886a6b | ||
|
|
d27c7ad590 | ||
|
|
a4d11cda2b | ||
|
|
c049e86fee | ||
|
|
5c93ac4aa5 | ||
|
|
9ca790df6a | ||
|
|
ee431bb340 | ||
|
|
0707092131 | ||
|
|
b2139b1400 | ||
|
|
a8fb40e689 | ||
|
|
25cdda9492 | ||
|
|
d6dc309408 | ||
|
|
6755341fbb | ||
|
|
2cb1b041dd | ||
|
|
5605b2aa94 | ||
|
|
fd4e13735c | ||
|
|
6acc82e9d3 | ||
|
|
d0456b1943 | ||
|
|
8aa55db1e8 | ||
|
|
afeb4d18f8 | ||
|
|
f214261ae5 | ||
|
|
aa6384babd | ||
|
|
ddb6d97bf0 | ||
|
|
187561e433 | ||
|
|
045de763f7 | ||
|
|
1962816c1e | ||
|
|
8c92c7d423 | ||
|
|
345d8e5240 | ||
|
|
4bda2dfea9 | ||
|
|
9292f58896 | ||
|
|
2ead7e7719 | ||
|
|
b3b1283d3b | ||
|
|
e1bda8e498 | ||
|
|
07d988488e | ||
|
|
0c68b26a8b | ||
|
|
3d86c738ff | ||
|
|
c9bcfa7638 | ||
|
|
0cfb9b6b94 | ||
|
|
8cc2ae0ba4 | ||
|
|
99dc8ddda5 | ||
|
|
5ea311f243 | ||
|
|
2185779d6c | ||
|
|
e0e50c9a8e | ||
|
|
c0622a7046 | ||
|
|
45bbb973a3 | ||
|
|
7917532716 | ||
|
|
fcaa84f0e4 | ||
|
|
b4518cbf33 | ||
|
|
72752b7db6 | ||
|
|
05d1f838d8 | ||
|
|
c97f823236 | ||
|
|
ffe76a7e57 | ||
|
|
98f6611362 | ||
|
|
99da5736db | ||
|
|
d9abac0f8e | ||
|
|
745e8afe45 | ||
|
|
b4a5b6a3ff | ||
|
|
a1de743ef6 | ||
|
|
a414f8ea85 | ||
|
|
39cb3dbb9c | ||
|
|
1977d330ed | ||
|
|
6c99efae52 | ||
|
|
6f753a142d |
236
.github/workflows/ci.yml
vendored
236
.github/workflows/ci.yml
vendored
@@ -18,6 +18,106 @@ env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
|
||||
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
|
||||
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
|
||||
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
|
||||
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
|
||||
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
|
||||
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
|
||||
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
|
||||
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect affected CI jobs
|
||||
id: filter
|
||||
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
|
||||
with:
|
||||
filters: |
|
||||
shared:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
- '.mise.toml'
|
||||
- '.tool-versions'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- 'scripts/**'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.base.json'
|
||||
- 'vitest.config.ts'
|
||||
quality:
|
||||
- 'packages/**'
|
||||
- '*.cjs'
|
||||
- '*.js'
|
||||
- '*.json'
|
||||
- '*.mjs'
|
||||
- '*.ts'
|
||||
server:
|
||||
- 'packages/app/e2e/fixtures/recording.*'
|
||||
- 'packages/client/**'
|
||||
- 'packages/cli/src/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop:
|
||||
- 'packages/app/**'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop_package:
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'packages/desktop/**'
|
||||
app:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
sdk:
|
||||
- 'packages/client/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
playwright:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
relay:
|
||||
- 'packages/relay/**'
|
||||
cli:
|
||||
- 'nix/**'
|
||||
- 'packages/app/e2e/global-setup.ts'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/src/daemon/runtime-paths.ts'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
|
||||
- name: Validate CI workflow
|
||||
run: node --test scripts/ci-workflow.test.mjs
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -39,6 +139,12 @@ jobs:
|
||||
run: npx oxfmt --check .
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -63,6 +169,12 @@ jobs:
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -89,6 +201,8 @@ jobs:
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,28 +211,43 @@ jobs:
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected server tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No server changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -126,6 +255,8 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -134,49 +265,53 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Skip unaffected desktop tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No desktop changes detected."
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Run real Electron browser tab bridge E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
|
||||
- name: Upload browser tab bridge diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure() && matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
@@ -184,7 +319,11 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
@@ -192,7 +331,11 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
@@ -201,6 +344,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -225,6 +374,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -251,6 +406,8 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -264,43 +421,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected Playwright tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No Playwright changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
if: env.RUN_TESTS == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && matrix.desktop
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: env.RUN_TESTS == 'true' && failure()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
@@ -309,6 +480,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -330,6 +507,8 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -338,21 +517,38 @@ jobs:
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected CLI tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No CLI changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
|
||||
18
.mise.toml
18
.mise.toml
@@ -1,10 +1,10 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
|
||||
_.path = [
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
|
||||
]
|
||||
[vars]
|
||||
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
|
||||
|
||||
[tools]
|
||||
java = "21"
|
||||
[env]
|
||||
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
|
||||
_.path = [
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
|
||||
]
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
// React Native style arrays must read Unistyles proxies during render. Hoisting them to
|
||||
// satisfy this allocation rule captures the temporary startup theme instead.
|
||||
"react-perf/jsx-no-new-array-as-prop": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,18 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
## 0.2.3 - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger))
|
||||
- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371))
|
||||
- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459))
|
||||
|
||||
### Improved
|
||||
|
||||
- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454))
|
||||
- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480))
|
||||
- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter))
|
||||
- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter))
|
||||
- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380))
|
||||
- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482))
|
||||
- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481))
|
||||
- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458))
|
||||
- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169))
|
||||
- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter))
|
||||
- `Shift+Tab` no longer changes a background agent’s permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter))
|
||||
- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter))
|
||||
- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476))
|
||||
- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457))
|
||||
|
||||
## 0.2.2 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude 5 models now use the correct context windows.
|
||||
|
||||
## 0.2.1 - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Opus 5 is available
|
||||
|
||||
## 0.2.0 - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
|
||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
|
||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
|
||||
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
|
||||
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
|
||||
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
|
||||
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
|
||||
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
|
||||
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
|
||||
@@ -20,12 +79,34 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
|
||||
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
|
||||
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
|
||||
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
|
||||
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
|
||||
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
|
||||
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
|
||||
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
|
||||
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
|
||||
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
|
||||
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
|
||||
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
|
||||
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
|
||||
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
|
||||
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
|
||||
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
|
||||
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
|
||||
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
|
||||
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
|
||||
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
|
||||
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
|
||||
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
|
||||
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
|
||||
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
|
||||
## 0.1.110 - 2026-07-16
|
||||
|
||||
17
README.ja.md
17
README.ja.md
@@ -151,23 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## コミュニティ
|
||||
## 関連プロジェクト
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
25
README.md
25
README.md
@@ -38,12 +38,6 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
|
||||
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
@@ -144,7 +138,7 @@ Quick monorepo package map:
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/relay`: Relay transport and encryption used by the daemon and clients
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
Common commands:
|
||||
@@ -166,23 +160,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Community
|
||||
## Related projects
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -151,9 +151,10 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 社区
|
||||
## 相关项目
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
@@ -202,18 +203,6 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
|
||||
|
||||
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
@@ -48,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
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.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
|
||||
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
|
||||
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
|
||||
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
|
||||
destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -10,7 +10,19 @@ initializing → idle → running → idle (or error → closed)
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Runtime residency
|
||||
|
||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
|
||||
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
|
||||
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -18,21 +30,14 @@ Cancellation changes lifecycle state only after the provider acknowledges the in
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||
|
||||
- `relationship` decides whether the new agent belongs under the caller.
|
||||
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
|
||||
|
||||
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
|
||||
|
||||
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
|
||||
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
@@ -46,7 +51,10 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
|
||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
||||
children.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
@@ -64,6 +72,20 @@ 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.
|
||||
|
||||
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,
|
||||
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
|
||||
received.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
@@ -139,11 +161,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
|
||||
|
||||
## Prerequisites (local dev)
|
||||
|
||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
|
||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
|
||||
|
||||
```bash
|
||||
mise install # java 21 + android-sdk 21.0 command-line tools
|
||||
```
|
||||
|
||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
|
||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
|
||||
|
||||
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
|
||||
|
||||
@@ -120,8 +120,20 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
|
||||
|
||||
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
|
||||
|
||||
For a single-ABI APK, pass React Native's architecture property to Gradle:
|
||||
|
||||
```bash
|
||||
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
|
||||
-PreactNativeArchitectures=arm64-v8a \
|
||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
|
||||
|
||||
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
|
||||
|
||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
@@ -54,22 +54,30 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
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
|
||||
for empty projects and without connected clients, then fans metadata changes through the WebSocket
|
||||
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -89,14 +97,22 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
The replica cache exists only to paint stale data immediately while the host connects. It does not
|
||||
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
|
||||
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
|
||||
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
|
||||
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
|
||||
written order; a single oversized host is omitted rather than partially restored.
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
@@ -105,27 +121,38 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo heartbeat create/update/delete`
|
||||
- `paseo workspace create/ls/archive`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||
- `paseo speech …`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
### `packages/relay` — Relay transport and E2E encryption
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
|
||||
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### Paseo Hub
|
||||
|
||||
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
|
||||
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
|
||||
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
@@ -185,7 +212,9 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
**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 and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
@@ -223,6 +252,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
|
||||
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
|
||||
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
|
||||
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
|
||||
messages or transfers.
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
@@ -354,5 +387,5 @@ $PASEO_HOME/
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Data Model
|
||||
|
||||
## Project identity
|
||||
|
||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||
|
||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
||||
service instead of constructing records independently. The workspace record is then the durable
|
||||
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
|
||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
@@ -58,8 +82,8 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
@@ -166,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
servicePorts?: { // optional dynamic service port allocation policy
|
||||
range?: string // inclusive range, e.g. "3000-4000"
|
||||
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
@@ -265,8 +293,8 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
|
||||
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
@@ -422,19 +450,22 @@ Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
||||
workspace `projectId` is authoritative when it names an active project, regardless of cwd
|
||||
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
|
||||
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
|
||||
workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
@@ -444,21 +475,25 @@ emptied duplicate.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The 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 |
|
||||
| `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) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
|
||||
@@ -64,6 +64,8 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
@@ -242,6 +243,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
|
||||
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
|
||||
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
|
||||
that reads what it needs from `process.env` and invoke it as
|
||||
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
|
||||
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
@@ -278,6 +287,15 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
||||
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
||||
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
||||
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
||||
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
||||
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
||||
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
||||
so the port may already be bound. `portScript` takes precedence when both values are present.
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
@@ -374,11 +392,14 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
@@ -389,6 +410,11 @@ Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
default; pass `--server <server-id>` when targeting another server.
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
@@ -176,8 +176,9 @@ IPs and `localhost` are allowed by default.
|
||||
|
||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||
want to expose the daemon port directly.
|
||||
- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for
|
||||
untrusted networks or mobile access when you do not want to expose the daemon
|
||||
port directly.
|
||||
- The container is the isolation boundary for agents. Agents can read and write
|
||||
whatever you mount into `/workspace` and whatever credentials you place in
|
||||
`/home/paseo`.
|
||||
|
||||
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
- **`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
|
||||
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
|
||||
@@ -70,7 +70,36 @@ Two escape hatches in the codebase:
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
||||
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
||||
ordinary portals regardless of `z-index`, which would hide app toasts and
|
||||
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||
lets tooltip portals paint above both.
|
||||
|
||||
The shared overlay scale is relative for interactive surfaces: a base floating
|
||||
panel is below a base modal, while a floating panel rendered from inside a modal
|
||||
inherits that modal's layer and paints above it. Wrap portal content in
|
||||
`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
|
||||
comboboxes must use `overlay-root` too. Rendering them through React Native
|
||||
Web's `<Modal>` puts them in the browser top layer, where no ordinary modal
|
||||
portal can cover them.
|
||||
|
||||
Painting and keyboard ownership use the same relative layer model. Register
|
||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
the React tree, carry the opener's current layer through the host store and
|
||||
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
|
||||
ownership silently reset at the app root. When the opener is a global keyboard
|
||||
action and has no component context to carry, resolve the host layer with
|
||||
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
|
||||
current top registered layer before the new host joins the stack; do not give a
|
||||
global dialog a fixed root-derived modal layer.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
@@ -123,6 +152,13 @@ lockstep, no re-measurement needed. Do not call
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
||||
leave the last height/progress pair stuck in either the open or closed state.
|
||||
Keep that terminal reconciliation on the UI thread; a later focus or blur must
|
||||
not be required to repair the offset.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
@@ -84,6 +84,20 @@ Register the adapter in `defaultForgeRegistry` with:
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
|
||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
||||
behind, or contain commits that have not been pushed yet.
|
||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
||||
selecting the newest terminal request by branch alone can silently attach an
|
||||
old promotion or feature request to new work.
|
||||
|
||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
||||
previous commit's cached terminal status until the cache expires.
|
||||
|
||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||
host that is either a known cloud host or one the CLI is already authenticated
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **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. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **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 affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, 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).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
@@ -29,7 +29,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **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`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
|
||||
86
docs/hub.md
Normal file
86
docs/hub.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Paseo Hub relationship
|
||||
|
||||
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect <url> --token <token>` from the daemon machine.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub.
|
||||
The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional
|
||||
encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch,
|
||||
or reconnects.
|
||||
|
||||
The daemon persists a relationship ID and private connection credential before enrollment. The
|
||||
relationship is independent of its current transport, so a future transport can replace the direct
|
||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||
|
||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
|
||||
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
|
||||
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
|
||||
shape.
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
|
||||
|
||||
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
|
||||
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
|
||||
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
|
||||
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
|
||||
response returns the existing agent and its current state; there is no separate reconciliation RPC.
|
||||
Transient stream frames are not durably replayed.
|
||||
|
||||
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
|
||||
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
|
||||
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
Hub creates use the same agent creation path as trusted clients. They may select any existing
|
||||
worktree target shape. Execution completion policy remains outside the daemon: a completed agent
|
||||
turn does not imply that the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
|
||||
actions are idempotent and continue to resolve from stored ownership after daemon restart.
|
||||
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
|
||||
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
|
||||
workspace through the shared workspace archive service, so the backing directory is removed only
|
||||
after its final active workspace reference disappears. Local and shared checkouts archive only the
|
||||
execution-owned agent.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter.
|
||||
Daemon restart loads the same relationship and credential and reconnects without another enrollment
|
||||
ceremony.
|
||||
|
||||
Hub authentication rejection or close code `4403` permanently revokes the local relationship. The
|
||||
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
|
||||
scopes, and a sanitized reason for status reporting.
|
||||
|
||||
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
|
||||
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
|
||||
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
|
||||
## Cross-repository compatibility
|
||||
|
||||
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
|
||||
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
|
||||
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
|
||||
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
|
||||
@@ -43,7 +43,8 @@ This architecture means:
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
- 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
|
||||
|
||||
## Target user
|
||||
|
||||
@@ -73,7 +74,7 @@ Anyone who builds software:
|
||||
- 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 (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
||||
- 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
|
||||
|
||||
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
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.
|
||||
|
||||
@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
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.
|
||||
|
||||
@@ -44,6 +44,8 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -38,6 +38,14 @@ There are two supported ways to ship from `main`:
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
|
||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
||||
a beta is promoted or superseded by a direct stable release.
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
@@ -76,9 +84,23 @@ npm run release:minor
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
|
||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||
stable release complete.
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
@@ -92,6 +114,7 @@ npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
|
||||
@@ -26,6 +26,18 @@ dev--feature-auth--miniweb.localhost
|
||||
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Managing workspace scripts
|
||||
|
||||
Configured `paseo.json` scripts can be managed without addressing their backing terminal directly:
|
||||
|
||||
```bash
|
||||
paseo script ls [--cwd <path> | --workspace <workspace-id>]
|
||||
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
|
||||
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
|
||||
```
|
||||
|
||||
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
@@ -95,6 +107,29 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here.
|
||||
|
||||
## Forwarded headers
|
||||
|
||||
Paseo sets these when it forwards a request to a workspace service:
|
||||
|
||||
| Header | Value |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one |
|
||||
| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) |
|
||||
| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
|
||||
| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set |
|
||||
|
||||
`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched.
|
||||
|
||||
Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`.
|
||||
|
||||
### The forwarded authority is not authenticated
|
||||
|
||||
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through.
|
||||
|
||||
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
|
||||
@@ -32,9 +32,14 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
|
||||
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
|
||||
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
|
||||
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
|
||||
queue wait, execution time, failures, timeouts, and top operations),
|
||||
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
|
||||
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
|
||||
command timeout begins only after a command acquires a limiter slot.
|
||||
|
||||
## Known remaining contention (follow-up candidates)
|
||||
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||
|
||||
@@ -177,6 +177,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -51,9 +51,55 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The host runtime owns each session replica for as long as the host remains registered. React
|
||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
|
||||
version.
|
||||
|
||||
## Projected pages reconcile with live presentation
|
||||
|
||||
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
|
||||
rows already received live—for example, a tool call retained at its original display position while
|
||||
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
|
||||
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||
instead of relocating visible user messages around newly fetched history.
|
||||
|
||||
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
|
||||
@@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u
|
||||
|
||||
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||
|
||||
### Do Not Materialize Styles At Module Scope
|
||||
|
||||
Never read a Unistyles style property into a module-level constant. This includes cached arrays:
|
||||
|
||||
```tsx
|
||||
// Wrong: evaluated while the app may still be using the temporary system theme.
|
||||
const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
|
||||
// Right: each style proxy is read when this view renders.
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} />;
|
||||
```
|
||||
|
||||
Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A
|
||||
module-level read can therefore materialize the light style before a persisted dark theme is
|
||||
active. If the view mounts after that theme change, React Native receives the stale light object;
|
||||
Unistyles registers the node for future changes but does not retroactively replace its initial
|
||||
props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly
|
||||
this reason.
|
||||
|
||||
Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule.
|
||||
Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside
|
||||
JSX, create the array inside the component or in a `useMemo` that first runs when the component
|
||||
mounts—never at module evaluation time.
|
||||
|
||||
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||
|
||||
## Dynamic Pixel Styles On Web
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-DL1LamUyFzJOkPYR7eeIefGhzP/mcWGO5oxld/Bt8n0=
|
||||
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
autoPatchelfHook,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
@@ -59,10 +60,13 @@ buildNpmPackage rec {
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
@@ -71,10 +75,9 @@ buildNpmPackage rec {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
|
||||
# speech runtime ships prebuilt platform packages and is copied into the
|
||||
# daemon closure by scripts/trace-daemon.mjs.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
@@ -89,7 +92,7 @@ buildNpmPackage rec {
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# forked terminal/speech worker processes) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
|
||||
210
package-lock.json
generated
210
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2531,10 +2531,35 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2554,22 +2579,33 @@
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
@@ -11055,6 +11091,19 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@replit/codemirror-vim": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@codemirror/commands": "6.x.x",
|
||||
"@codemirror/language": "6.x.x",
|
||||
"@codemirror/search": "6.x.x",
|
||||
"@codemirror/state": "6.x.x",
|
||||
"@codemirror/view": "6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
@@ -35162,8 +35211,13 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -35178,6 +35232,7 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -36181,18 +36236,19 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/server": "0.2.0-beta.1",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/server": "0.2.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.4"
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -36432,10 +36488,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36446,7 +36502,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36689,7 +36745,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37585,9 +37641,9 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37817,7 +37873,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37830,7 +37886,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38048,15 +38104,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/highlight": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/highlight": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38100,22 +38156,22 @@
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz",
|
||||
"integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -38123,10 +38179,10 @@
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38136,10 +38192,10 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38149,10 +38205,10 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38162,10 +38218,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz",
|
||||
"integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38175,10 +38231,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38188,10 +38244,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz",
|
||||
"integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38201,10 +38257,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38214,10 +38270,10 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38593,7 +38649,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -49,9 +49,9 @@
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
@@ -132,6 +132,8 @@
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectLoadedTimelineDoesNotScroll,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptVisible,
|
||||
holdNextOlderTimelinePage,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
@@ -25,4 +28,21 @@ test.describe("Agent timeline pagination", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
try {
|
||||
await makeLoadedTimelineFitViewport(page);
|
||||
const olderPage = await holdNextOlderTimelinePage(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectLoadedTimelineDoesNotScroll(page);
|
||||
await olderPage.expectLoading();
|
||||
olderPage.release();
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { openSessions } from "./helpers/archive-tab";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "./helpers/rewind-flow";
|
||||
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface TimelineClient extends SeedDaemonClient {
|
||||
fetchAgentTimeline(
|
||||
agentId: string,
|
||||
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||
|
||||
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||
}
|
||||
|
||||
test.describe("archived Codex agent recovery", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
await sendMessage(handle, INITIAL_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await handle.client.archiveAgent(handle.agentId);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const timelineClient = handle.client as TimelineClient;
|
||||
await expect(
|
||||
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 100,
|
||||
}),
|
||||
).rejects.toThrow(/archiv/i);
|
||||
await expect
|
||||
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||
]);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -123,4 +123,16 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector closes after model selection", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
|
||||
|
||||
await sheet.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,9 +55,15 @@ test.describe("Command center workspaces", () => {
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(WORKSPACE_TITLE);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toContainText(WORKSPACE_BRANCH);
|
||||
|
||||
// The subtitle disambiguates by project: host · project · branch (multi-host).
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(subtitle).toContainText(seeded.projectDisplayName);
|
||||
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
|
||||
|
||||
// The agent subtitle is unchanged by the shared-helper refactor.
|
||||
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(agentRow).toContainText(AGENT_TITLE);
|
||||
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
|
||||
@@ -89,6 +95,10 @@ test.describe("Command center workspaces", () => {
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
// The project name is now part of the workspace searchText.
|
||||
await input.fill(seeded.projectDisplayName);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await input.fill(AGENT_TITLE);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
@@ -103,4 +113,38 @@ test.describe("Command center workspaces", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-single-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// No secondary host: with a single host, the host label is gated away.
|
||||
await gotoAppShell(page);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,25 @@ import { test, expect } from "./fixtures";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
test("commit history explains when the workspace has no commits ahead of its base", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
|
||||
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
|
||||
"No commits ahead of main yet",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("commit history shows dates and shares diff layout preferences", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -23,31 +42,26 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
hasText: COMMIT_SUBJECT,
|
||||
});
|
||||
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
|
||||
await expect(commitRow).toContainText("Jan 15");
|
||||
await commitRow.click();
|
||||
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
@@ -10,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -218,6 +223,99 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
await page.getByTestId("diff-file-1-actions").click();
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||
await changesTabToggle.click();
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||
"Scroll long lines",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||
"zz-deleted.ts",
|
||||
);
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("Deleted");
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -243,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
@@ -250,6 +353,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
const changesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "changes-options-menu"),
|
||||
readSvgRight(page, "diff-file-0-actions"),
|
||||
]);
|
||||
expectAligned(changesRightRail);
|
||||
|
||||
const [folderStat, fileStat] = await Promise.all([
|
||||
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(folderStat).not.toBeNull();
|
||||
expect(fileStat).not.toBeNull();
|
||||
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
|
||||
const filesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "files-refresh"),
|
||||
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||
]);
|
||||
expectAligned(filesRightRail);
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(sortLabel).not.toBeNull();
|
||||
expect(firstRowIcon).not.toBeNull();
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
async function createWorkspaceWithMountedTabDiff(
|
||||
options: WorkspaceFixtureOptions = {},
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -436,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not measure ${testID}`);
|
||||
}
|
||||
return box.x + box.width;
|
||||
}
|
||||
|
||||
function expectAligned(values: number[]): void {
|
||||
const [first, ...rest] = values;
|
||||
for (const value of rest) {
|
||||
expect(value).toBeCloseTo(first, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
|
||||
19
packages/app/e2e/directory-bootstrap.spec.ts
Normal file
19
packages/app/e2e/directory-bootstrap.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test } from "./fixtures";
|
||||
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
|
||||
|
||||
test.describe("Directory bootstrap correctness", () => {
|
||||
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const scenario = await DirectoryBootstrapScenario.open(page);
|
||||
try {
|
||||
await scenario.expectDirectoryStarts(1);
|
||||
await scenario.stayConnectedWithoutRefetchAndApplyDeltas();
|
||||
await scenario.disconnectMutateAndReconnect();
|
||||
await scenario.expectVisibleReconciliationAndNavigateAgent();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
422
packages/app/e2e/file-editing.spec.ts
Normal file
422
packages/app/e2e/file-editing.spec.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
function fitsViewportWidth(element: HTMLElement): boolean {
|
||||
return element.scrollWidth === element.clientWidth;
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(target: string) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-editing-chat-link-",
|
||||
title: "Chat file link e2e",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "target.ts"),
|
||||
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
const fileLink = page.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expectFileTabOpen(page, "target.ts");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(fileLink).toBeVisible();
|
||||
await fileLink.click();
|
||||
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await editor(page).click();
|
||||
await page.keyboard.press("Alt+Shift+W");
|
||||
|
||||
await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||
const relativePath = "src/deep/visuals.md";
|
||||
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||
await openFileFromExplorer(page, "visuals.md");
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||
await fileTab.hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||
|
||||
await content.click();
|
||||
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
});
|
||||
|
||||
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
||||
});
|
||||
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
||||
const relativePath = "tooltip-font.txt";
|
||||
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, relativePath);
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
||||
.getByText(relativePath, { exact: true }),
|
||||
).toHaveCSS("font-family", "monospace");
|
||||
});
|
||||
|
||||
test("wraps Markdown while source code remains horizontally scrollable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-wrap-" });
|
||||
const longLine = "word ".repeat(300);
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "source.ts"),
|
||||
`const value = "${longLine}";\n`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-scroller");
|
||||
await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true);
|
||||
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
const sourceScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-scroller");
|
||||
await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await Promise.all(
|
||||
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||
),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||
|
||||
await replaceEditorText(page, "const immediate = 3;\n");
|
||||
await editor(page).press("Control+s");
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||
|
||||
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const external = 4;");
|
||||
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||
|
||||
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||
await gate.drop();
|
||||
gate.restore();
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(subscriptionCount);
|
||||
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||
});
|
||||
|
||||
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "windows.ts");
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "windows.ts");
|
||||
|
||||
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
|
||||
await editor(page).press("Control+s");
|
||||
|
||||
const expected = Buffer.from(
|
||||
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
|
||||
"utf8",
|
||||
).toString("hex");
|
||||
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
|
||||
});
|
||||
|
||||
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "draft.ts");
|
||||
|
||||
await replaceEditorText(page, "const local = 2;\n");
|
||||
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_draft.ts")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||
|
||||
await page.goto("/settings/editor");
|
||||
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "vim.ts");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
await editor(page).click();
|
||||
await editor(page).press("i");
|
||||
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||
await editor(page).press("Escape");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
export interface WaitForServerOptions {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
@@ -22,6 +22,8 @@ interface WaitForServerOptions {
|
||||
getRecentOutput?: () => string;
|
||||
}
|
||||
|
||||
type ServerProbe = (host: string, port: number) => Promise<void>;
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -75,7 +77,25 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
async function connectToServer(host: string, port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
port: number,
|
||||
options: WaitForServerOptions,
|
||||
probe: ServerProbe = connectToServer,
|
||||
): Promise<void> {
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
@@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
await probe(host, port);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
@@ -116,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
);
|
||||
}
|
||||
|
||||
async function probeMetro(host: string, port: number): Promise<void> {
|
||||
const response = await fetch(`http://${host}:${port}/status`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
const body = (await response.text()).trim();
|
||||
if (response.status !== 200 || body !== "packager-status:running") {
|
||||
throw new Error(
|
||||
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
await waitForServer(port, options, probeMetro);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
@@ -302,6 +328,12 @@ interface PairingDaemonClient {
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
} catch {
|
||||
// The local PR fixture below remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -323,6 +355,70 @@ if (recordPath) {
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
const fakeGhSource = `#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const args = process.argv.slice(2);
|
||||
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).stdout?.trim();
|
||||
|
||||
if (origin === fixtureRemote) {
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
if (command === "auth status") process.exit(0);
|
||||
if (command === "repo view") {
|
||||
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "issue list") {
|
||||
process.stdout.write("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const pr = {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||
if (!realGhPath) process.exit(127);
|
||||
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -556,13 +652,19 @@ async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<nu
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
interface RelayPorts {
|
||||
relayPort: number;
|
||||
inspectorPort: number;
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<RelayPorts> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -576,6 +678,10 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--inspector-ip",
|
||||
"127.0.0.1",
|
||||
"--inspector-port",
|
||||
String(inspectorPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
@@ -590,7 +696,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
|
||||
try {
|
||||
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||
return relayPort;
|
||||
return { relayPort, inspectorPort };
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
@@ -767,12 +873,19 @@ export default async function globalSetup() {
|
||||
await logSpeechHarnessConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro({
|
||||
metroPort,
|
||||
daemonPort: port,
|
||||
buffer: metroLineBuffer,
|
||||
});
|
||||
await waitForMetro(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
});
|
||||
|
||||
const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort]));
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
@@ -783,19 +896,11 @@ export default async function globalSetup() {
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
await waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
});
|
||||
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
@@ -809,7 +914,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
|
||||
@@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate {
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentTimelineResponseGate {
|
||||
release(): void;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ interface E2EDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||
function resolveDaemonWsUrl(port?: number): string {
|
||||
return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
): Promise<ClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||
const client = new DaemonClient({
|
||||
url: resolveDaemonWsUrl(),
|
||||
url: resolveDaemonWsUrl(options.port),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
114
packages/app/e2e/helpers/daemon-websocket-gate.ts
Normal file
114
packages/app/e2e/helpers/daemon-websocket-gate.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface DirectoryBootstrapCounts {
|
||||
agents: number;
|
||||
workspaces: number;
|
||||
}
|
||||
|
||||
export interface DirectoryRequestStartCounts {
|
||||
subscribed: DirectoryBootstrapCounts;
|
||||
unsubscribed: DirectoryBootstrapCounts;
|
||||
total: DirectoryBootstrapCounts;
|
||||
}
|
||||
|
||||
interface ClientRequest {
|
||||
type?: unknown;
|
||||
subscribe?: unknown;
|
||||
page?: { cursor?: unknown };
|
||||
}
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
|
||||
if (request.page?.cursor) return null;
|
||||
if (request.type === "fetch_agents_request") return "agents";
|
||||
if (request.type === "fetch_workspaces_request") return "workspaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
const request = readClientRequest(message);
|
||||
if (typeof request?.type === "string") {
|
||||
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
||||
const directory = directoryForRequest(request);
|
||||
if (directory) {
|
||||
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
|
||||
directoryStarts[subscription][directory] += 1;
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(
|
||||
sockets.map((ws) =>
|
||||
ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
||||
return {
|
||||
subscribed: { ...directoryStarts.subscribed },
|
||||
unsubscribed: { ...directoryStarts.unsubscribed },
|
||||
total: { ...directoryStarts.total },
|
||||
};
|
||||
},
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
|
||||
daemonListen?: string;
|
||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||
hangDaemonStart?: boolean;
|
||||
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||
desktopSettingsDelayMs?: number;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
@@ -101,6 +103,7 @@ declare global {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
__desktopDaemonStartRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
window.__desktopDaemonStartRequested = false;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
window.__desktopDaemonStartRequested = true;
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
async function waitForDesktopSettingsResponse() {
|
||||
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
await waitForDesktopSettingsResponse();
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||
// Give the startup state two paints to expose any app → splash regression.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
|
||||
155
packages/app/e2e/helpers/directory-bootstrap-scenario.ts
Normal file
155
packages/app/e2e/helpers/directory-bootstrap-scenario.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
interface SeededDirectoryAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
async function createRunningMockAgent(
|
||||
workspace: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<SeededDirectoryAgent> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: `Keep ${title} running for directory synchronization.`,
|
||||
});
|
||||
const running = await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000,
|
||||
);
|
||||
expect(running.status).toBe("running");
|
||||
return { id: agent.id, title };
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open command center" }).click();
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
private readonly workspaces: SeededWorkspace[] = [];
|
||||
private disconnectedWorkspace: SeededWorkspace | null = null;
|
||||
private disconnectedAgent: SeededDirectoryAgent | null = null;
|
||||
|
||||
private constructor(
|
||||
private readonly page: Page,
|
||||
private readonly gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {}
|
||||
|
||||
static async open(page: Page): Promise<DirectoryBootstrapScenario> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const scenario = new DirectoryBootstrapScenario(page, gate);
|
||||
const workspace = await scenario.seedWorkspace("directory-bootstrap-initial-");
|
||||
const agent = await createRunningMockAgent(workspace, "Initial directory agent");
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, workspace.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page.getByRole("button", { name: agent.title, exact: true })).toBeVisible();
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async expectDirectoryStarts(expectedPerDirectory: number): Promise<void> {
|
||||
await expect
|
||||
.poll(() => this.gate.getDirectoryRequestStartCounts())
|
||||
.toEqual({
|
||||
subscribed: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
|
||||
});
|
||||
}
|
||||
|
||||
async stayConnectedWithoutRefetchAndApplyDeltas(): Promise<void> {
|
||||
const workspace = await this.seedWorkspace("directory-bootstrap-background-");
|
||||
const agent = await createRunningMockAgent(workspace, "Background directory agent");
|
||||
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.expectDirectoryStarts(1);
|
||||
}
|
||||
|
||||
async disconnectMutateAndReconnect(): Promise<void> {
|
||||
await this.gate.drop();
|
||||
await expectReconnectingToastVisible(this.page);
|
||||
|
||||
this.disconnectedWorkspace = await this.seedWorkspace("directory-bootstrap-reconnect-");
|
||||
this.disconnectedAgent = await createRunningMockAgent(
|
||||
this.disconnectedWorkspace,
|
||||
"Reconnected directory agent",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByText(this.disconnectedWorkspace.projectDisplayName, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(this.page.getByText(this.disconnectedAgent.title, { exact: true })).toHaveCount(0);
|
||||
|
||||
this.gate.restore();
|
||||
await expectReconnectingToastGone(this.page);
|
||||
await this.expectDirectoryStarts(2);
|
||||
}
|
||||
|
||||
async expectVisibleReconciliationAndNavigateAgent(): Promise<void> {
|
||||
const workspace = this.requireDisconnectedWorkspace();
|
||||
const agent = this.requireDisconnectedAgent();
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await agentLink.click();
|
||||
await expect(this.page).toHaveURL(
|
||||
new RegExp(
|
||||
`/workspace/${workspace.workspaceId}/agent/${agent.id}|/workspace/${workspace.workspaceId}`,
|
||||
),
|
||||
);
|
||||
await expect(this.page.getByRole("button", { name: agent.title, exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const pings = this.gate.getClientRequestCount("ping");
|
||||
await expect
|
||||
.poll(() => this.gate.getClientRequestCount("ping"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(pings);
|
||||
await this.expectDirectoryStarts(2);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.gate.restore();
|
||||
await Promise.all(this.workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
|
||||
private async seedWorkspace(prefix: string): Promise<SeededWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: prefix });
|
||||
this.workspaces.push(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private requireDisconnectedWorkspace(): SeededWorkspace {
|
||||
if (!this.disconnectedWorkspace) throw new Error("Reconnect workspace was not seeded.");
|
||||
return this.disconnectedWorkspace;
|
||||
}
|
||||
|
||||
private requireDisconnectedAgent(): SeededDirectoryAgent {
|
||||
if (!this.disconnectedAgent) throw new Error("Reconnect agent was not seeded.");
|
||||
return this.disconnectedAgent;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalGhPrFixture {
|
||||
pr: GhPrFixture;
|
||||
mainCheckout: GhDefaultBranchClone;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
@@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||
const basePath = path.join(fixtureRoot, "base");
|
||||
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
await mkdir(basePath);
|
||||
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||
git(["add", "pr-1.txt"], basePath);
|
||||
git(["commit", "-m", "Add PR 1"], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
|
||||
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||
execFileSync(
|
||||
"git",
|
||||
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||
|
||||
return {
|
||||
pr: {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
branch: "pr-branch-1",
|
||||
localPath: basePath,
|
||||
},
|
||||
mainCheckout: {
|
||||
path: checkoutPath,
|
||||
cleanup: async () => {},
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -85,43 +86,61 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
child = await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
export async function connectNewWorkspaceDaemonClient(options?: {
|
||||
port?: number;
|
||||
}): Promise<NewWorkspaceDaemonClient> {
|
||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||
clientIdPrefix: "app-e2e-new-workspace",
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(searchInput).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
projectDisplayName: string,
|
||||
@@ -287,6 +298,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(name);
|
||||
await selectBranchInPicker(page, name);
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
@@ -348,6 +366,18 @@ export async function expectComposerGithubAttachmentPill(
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function pasteGithubPrUrl(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -158,10 +158,11 @@ export interface SeedDaemonClient {
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||
export async function connectSeedClient(options?: { port?: number }): Promise<SeedDaemonClient> {
|
||||
return connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -39,6 +39,13 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function pinWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
|
||||
const pinItem = page.getByTestId(`sidebar-workspace-menu-pin-${serverId}:${workspaceId}`);
|
||||
await expect(pinItem).toBeVisible({ timeout: 10_000 });
|
||||
await pinItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
|
||||
55
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
55
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SessionMessage {
|
||||
type?: unknown;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface TimelineSubscriptionWaitOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: SessionMessage;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function observeTimelineSubscriptions(page: Page) {
|
||||
let acknowledgedAgentIds: string[] | null = null;
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const message = readSessionMessage(payload);
|
||||
if (message?.type !== "agent.timeline.set_subscription.response") return;
|
||||
const response = message.payload as { agentIds?: unknown } | undefined;
|
||||
if (!Array.isArray(response?.agentIds)) return;
|
||||
acknowledgedAgentIds = response.agentIds.filter(
|
||||
(agentId): agentId is string => typeof agentId === "string",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(
|
||||
agentIds: string[],
|
||||
options: TimelineSubscriptionWaitOptions = {},
|
||||
): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||
timeout: options.timeout ?? 15_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentOlderTimelineResponse,
|
||||
type AgentTimelineResponseGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
@@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight <= element.clientHeight;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
|
||||
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
|
||||
return {
|
||||
...gate,
|
||||
async expectLoading() {
|
||||
await gate.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
|
||||
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
|
||||
const option = page
|
||||
.getByRole("dialog")
|
||||
.last()
|
||||
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
|
||||
.first();
|
||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||
await option.click({ force: true });
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
openNewWorkspaceProjectPickerWithShortcut,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
146
packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts
Normal file
146
packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute } from "./helpers/mock-agent";
|
||||
import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject } from "./helpers/new-workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The draft mode control in New Workspace only mutates local form state; it never sends
|
||||
// set_agent_mode_request. So the only source of such a request while the New Workspace
|
||||
// composer is focused is a *live* agent's mode control. Recording those, keyed by agentId,
|
||||
// gives a direct signal that Shift+Tab leaked into a backgrounded agent.
|
||||
async function recordSetAgentModeRequests(page: Page): Promise<{
|
||||
requestsForAgent(agentId: string): Array<{ agentId: string; modeId: string }>;
|
||||
}> {
|
||||
const seen: Array<{ agentId: string; modeId: string }> = [];
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "set_agent_mode_request") {
|
||||
const agentId = typeof sessionMessage.agentId === "string" ? sessionMessage.agentId : "";
|
||||
const modeId = typeof sessionMessage.modeId === "string" ? sessionMessage.modeId : "";
|
||||
seen.push({ agentId, modeId });
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
return {
|
||||
requestsForAgent: (agentId: string) => seen.filter((request) => request.agentId === agentId),
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
// Focus the New Workspace composer and cycle the execution mode with the keyboard.
|
||||
// Kept out of the test body so the test reads as intent rather than key mechanics.
|
||||
async function cycleNewWorkspaceMode(page: Page, presses: number): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.click();
|
||||
for (let i = 0; i < presses; i++) {
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("New Workspace mode cycle safety", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
// Regression guard for the P1 safety bug: cycling the execution mode with Shift+Tab in
|
||||
// the New Workspace composer must never reach a backgrounded, still-mounted agent's mode
|
||||
// control and silently change that (possibly running) agent's mode — e.g. into a
|
||||
// permissive/bypass mode. See use-keyboard-action-handler.ts.
|
||||
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
|
||||
await seedCodexDefaultPreferences(page, serverId);
|
||||
const modeRequests = await recordSetAgentModeRequests(page);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "mode cycle safety e2e",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
|
||||
// Mount the live agent tab: its mode control registers a mode-cycle keyboard handler.
|
||||
await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id });
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Move to the New Workspace composer. The agent tab stays mounted in the background,
|
||||
// so its handler is still registered when we cycle here.
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await cycleNewWorkspaceMode(page, 6);
|
||||
|
||||
// fetchAgents is a real daemon round-trip; once it resolves, any mode change the
|
||||
// presses would have triggered has already landed. Assert the running agent is
|
||||
// untouched — both its committed mode and on the wire — with no fixed sleep.
|
||||
const agents = await seeded.client.fetchAgents();
|
||||
const backgroundAgent = agents.entries.find((entry) => entry.agent.id === agent.id)?.agent;
|
||||
expect(backgroundAgent?.currentModeId).toBe("auto");
|
||||
expect(modeRequests.requestsForAgent(agent.id)).toEqual([]);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
@@ -30,9 +33,11 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
createTempGithubRepo,
|
||||
hasGithubAuth,
|
||||
type LocalGhPrFixture,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const fixture of localGithubFixtures) {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
localGithubFixtures.clear();
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
@@ -807,6 +820,100 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
});
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: worktree.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await searchAndSelectBranchInPicker(page, "main");
|
||||
await expectPickerSelected(page, "main");
|
||||
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||
await expectPickerSelected(page, "main");
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
@@ -63,6 +64,33 @@ async function closeSheetByHeaderButton(page: Page, testId: string) {
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) {
|
||||
const frontCoversBack = await page.evaluate(
|
||||
({ frontTestId: frontId, backTestId: backId }) => {
|
||||
const front = document.querySelector(`[data-testid="${frontId}"]`);
|
||||
const back = document.querySelector(`[data-testid="${backId}"]`);
|
||||
if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false;
|
||||
|
||||
const frontRect = front.getBoundingClientRect();
|
||||
const backRect = back.getBoundingClientRect();
|
||||
const left = Math.max(frontRect.left, backRect.left);
|
||||
const right = Math.min(frontRect.right, backRect.right);
|
||||
const top = Math.max(frontRect.top, backRect.top);
|
||||
const bottom = Math.min(frontRect.bottom, backRect.bottom);
|
||||
if (left >= right || top >= bottom) return false;
|
||||
|
||||
const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
return topElement != null && front.contains(topElement);
|
||||
},
|
||||
{ frontTestId, backTestId },
|
||||
);
|
||||
expect(frontCoversBack).toBe(true);
|
||||
}
|
||||
|
||||
async function hasFocusWithin(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((element) => element.contains(document.activeElement));
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
@@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expectProviderSettingsVisible(page);
|
||||
}
|
||||
|
||||
test.describe("provider settings bottom-sheet stack", () => {
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-modal-layer-",
|
||||
title: "Provider modal layer e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
const selector = page.getByTestId("combobox-desktop-container");
|
||||
await expect(selector).toBeVisible({ timeout: 10_000 });
|
||||
const searchInput = page.getByRole("textbox", { name: /search models/i });
|
||||
await expect(searchInput).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect.poll(() => hasFocusWithin(selector)).toBe(true);
|
||||
|
||||
await page.keyboard.press("Shift+?");
|
||||
const shortcuts = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
await expect(shortcuts).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(shortcuts).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
const commandCenter = page.getByTestId("command-center-panel");
|
||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||
await expect(commandCenter.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(commandCenter).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
const settingsButton = page.getByTestId("selector-header-settings-mock");
|
||||
await settingsButton.click();
|
||||
|
||||
const settings = page.getByTestId("provider-settings-sheet");
|
||||
await expect(settings).toBeVisible({ timeout: 10_000 });
|
||||
await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(settings).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(settingsButton).toBeFocused();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => {
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const search = page.getByPlaceholder("Search shortcuts");
|
||||
|
||||
await search.fill("command+n");
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
|
||||
|
||||
await search.fill("interrupt");
|
||||
|
||||
await expect(dialog.getByText("Interrupt agent", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toHaveCount(0);
|
||||
|
||||
await search.fill("no matching shortcut");
|
||||
await expect(dialog.getByText("No results found", { exact: true })).toBeVisible();
|
||||
|
||||
await search.fill("");
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
266
packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts
Normal file
266
packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
|
||||
// whenever the row was unmounted — a collapsed project section being the common case. It now
|
||||
// lives in a single always-mounted handler keyed on the active route selection.
|
||||
const PIN_SHORTCUT = "ControlOrMeta+Shift+P";
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
function pinnedSection(page: Page) {
|
||||
return page.getByTestId("sidebar-pinned-section");
|
||||
}
|
||||
|
||||
// Opens the workspace so it becomes the active route selection, which is what the shortcut acts on.
|
||||
async function openWorkspace(page: Page, workspaceId: string) {
|
||||
const row = workspaceRow(page, workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The project key is host-scoped and not exposed by the seed helper, so the header is addressed by
|
||||
// its display name, scoped to project rows so a workspace row can never match. Pressing the header
|
||||
// toggles the section, which unmounts every workspace row under it.
|
||||
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: project.projectDisplayName });
|
||||
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Status mode buckets workspaces by state rather than project, so the group holding this workspace
|
||||
// is discovered from the rows container it sits in rather than assumed.
|
||||
async function collapseStatusGroupContaining(page: Page, workspaceId: string): Promise<void> {
|
||||
const rows = page
|
||||
.locator('[data-testid^="sidebar-status-group-rows-"]')
|
||||
.filter({ has: workspaceRow(page, workspaceId) });
|
||||
await expect(rows).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
const rowsTestId = await rows.getAttribute("data-testid");
|
||||
const bucket = rowsTestId?.replace("sidebar-status-group-rows-", "");
|
||||
expect(bucket).toBeTruthy();
|
||||
|
||||
await page.getByTestId(`sidebar-status-group-${bucket}`).click();
|
||||
await expect(workspaceRow(page, workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
function readSessionMessage(
|
||||
message: string | Buffer,
|
||||
): { type?: unknown; requestId?: unknown } | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
if (envelope.type !== "session" || typeof envelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return envelope.message as { type?: unknown; requestId?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PIN_REJECTION_MESSAGE = "Pin rejected by test.";
|
||||
|
||||
interface PinRpcGate {
|
||||
/** Pin requests the client has sent so far. */
|
||||
sentCount(): number;
|
||||
}
|
||||
|
||||
// Proxies everything so the app boots against the real daemon, counting pin RPCs and optionally
|
||||
// rejecting the first `rejectFirst` of them. The count asserts how many pins one keypress actually
|
||||
// dispatched, which the rendered pin state cannot show — a toggle that fired twice lands back
|
||||
// where it started.
|
||||
async function installPinRpcGate(
|
||||
page: Page,
|
||||
options: { rejectFirst?: number } = {},
|
||||
): Promise<PinRpcGate> {
|
||||
const rejectFirst = options.rejectFirst ?? 0;
|
||||
let sent = 0;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = readSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "workspace.pin.set.request" &&
|
||||
typeof sessionMessage.requestId === "string"
|
||||
) {
|
||||
sent += 1;
|
||||
if (sent <= rejectFirst) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: sessionMessage.requestId,
|
||||
requestType: "workspace.pin.set.request",
|
||||
error: PIN_REJECTION_MESSAGE,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { sentCount: () => sent };
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
pinnedSection(page).getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
||||
// shadowed second handler is unobservable from outside by design.
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await switchToStatusGrouping(page);
|
||||
await collapseStatusGroupContaining(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
pinnedSection(page).getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an error toast when the host rejects the pin, and the next press succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-failure-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page, { rejectFirst: 1 });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(PIN_REJECTION_MESSAGE, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(pinnedSection(page)).toHaveCount(0);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
|
||||
// The failure must leave the action usable: the in-flight guard has to release the key so a
|
||||
// retry is not swallowed. Without that release the workspace is unpinnable for the session.
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "./helpers/sidebar";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
@@ -46,24 +47,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -96,21 +98,24 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: "test-owner/test-repo",
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -164,6 +169,30 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
|
||||
await expect(row).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Half-screen desktop layout", () => {
|
||||
@@ -190,6 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
*
|
||||
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
||||
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
||||
* stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events)
|
||||
* stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events)
|
||||
* instead of racing real OS focus.
|
||||
*
|
||||
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
||||
@@ -23,7 +23,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through
|
||||
* `document.hasFocus()` plus the window focus/blur events.
|
||||
*
|
||||
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
||||
|
||||
238
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
238
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface ShimmerEvidence {
|
||||
animationDuration: string;
|
||||
endPx: number;
|
||||
label: string;
|
||||
renderedWidth: number;
|
||||
startPx: number;
|
||||
}
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) 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;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let releaseSecondRequested = false;
|
||||
let pauseServerMessages = false;
|
||||
let secondRunningForwarded = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (!secondRunningMessage || secondRunningForwarded) {
|
||||
return;
|
||||
}
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningForwarded = true;
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseServerMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
if (toolCall.status === "running" || toolCall.status === "executing") {
|
||||
return;
|
||||
}
|
||||
if (toolCall.status === "completed") {
|
||||
// The mock tool completes inside the daemon's coalescing window, so the
|
||||
// browser naturally receives its authoritative completed state first.
|
||||
ws.send(message);
|
||||
resolveFirstCompleted();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (
|
||||
toolCall.callId === secondCallId &&
|
||||
(toolCall.status === "running" || toolCall.status === "executing")
|
||||
) {
|
||||
secondRunningMessage = message;
|
||||
pauseServerMessages = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
|
||||
// Keep the second real tool call inspectably active. Production providers
|
||||
// send this same status shape while their work remains in flight.
|
||||
secondRunningMessage = replaceToolCallStatus(message, "running");
|
||||
pauseServerMessages = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
if (secondRunningMessage) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
|
||||
return locator.evaluate((root) => {
|
||||
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
|
||||
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
|
||||
);
|
||||
if (!shimmer) {
|
||||
throw new Error("Expected a running shimmer inside the badge");
|
||||
}
|
||||
const style = getComputedStyle(shimmer);
|
||||
return {
|
||||
animationDuration: style.animationDuration,
|
||||
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
|
||||
label: shimmer.textContent ?? "",
|
||||
renderedWidth: shimmer.getBoundingClientRect().width,
|
||||
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("measures an overview heading that becomes loading after its idle mount", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-shimmer-",
|
||||
title: "Tool-call shimmer",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
|
||||
|
||||
await gate.waitForFirstCompleted();
|
||||
const group = page.getByTestId("tool-call-group");
|
||||
await expect(group).toBeVisible();
|
||||
const idleGroupHandle = await group.elementHandle();
|
||||
if (!idleGroupHandle) {
|
||||
throw new Error("Expected the idle tool-call group to be mounted");
|
||||
}
|
||||
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
|
||||
const sameGroupNode = await group.evaluate(
|
||||
(node, previous) => node === previous,
|
||||
idleGroupHandle,
|
||||
);
|
||||
|
||||
await group.click();
|
||||
const runningChild = page.getByTestId("tool-call-badge").last();
|
||||
await expect(runningChild).toBeVisible();
|
||||
const header = await readShimmerEvidence(group);
|
||||
const child = await readShimmerEvidence(runningChild);
|
||||
const evidence = { sameGroupNode, header, child };
|
||||
await testInfo.attach("tool-call-shimmer-evidence", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
expect(sameGroupNode).toBe(true);
|
||||
expect(child.endPx).toBeGreaterThan(0);
|
||||
expect(
|
||||
header.endPx,
|
||||
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
|
||||
).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
179
packages/app/e2e/viewed-agent-timelines.spec.ts
Normal file
179
packages/app/e2e/viewed-agent-timelines.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "./fixtures";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface ViewedTimelineScenario {
|
||||
client: SeedDaemonClient;
|
||||
workspaceId: string;
|
||||
firstAgentId: string;
|
||||
secondAgentId: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
||||
const createAgent = (title: string) =>
|
||||
workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat"),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function selectAgent(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function enableMoveTabShortcut(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
}
|
||||
|
||||
async function moveActiveTabRight(page: Page) {
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
}
|
||||
|
||||
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
|
||||
await scenario.client.sendAgentMessage(agentId, prompt);
|
||||
const finish = await scenario.client.waitForFinish(agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed while the first chat is hidden.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("two visible split chats both stay current", async ({ page }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await page.getByRole("button", { name: "Split pane right" }).click();
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await moveActiveTabRight(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "First viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
|
||||
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a visible chat catches up after reconnecting", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(recoveredMessage).toHaveCount(1);
|
||||
await expect(recoveredMessage).toBeVisible();
|
||||
} finally {
|
||||
gate.restore();
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
@@ -43,6 +44,49 @@ async function fetchActiveAgentTitle(
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
|
||||
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
await submitMessage(page, prompt);
|
||||
const agentId = await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
|
||||
|
||||
const overlayAppeared = page
|
||||
.getByTestId("agent-history-overlay")
|
||||
.waitFor({ state: "attached", timeout: 2_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
|
||||
expect(await overlayAppeared).toBe(false);
|
||||
} finally {
|
||||
timelineGate.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
|
||||
const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
|
||||
interface RestartDaemonClient {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -32,10 +35,53 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
type StartupPresentation = "splash" | "app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||
}
|
||||
}
|
||||
|
||||
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const trace: StartupPresentation[] = [];
|
||||
window.__paseoStartupPresentationTrace = trace;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const recordPresentation = () => {
|
||||
let presentation: StartupPresentation | null = null;
|
||||
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||
presentation = "splash";
|
||||
} else if (
|
||||
document.querySelector(
|
||||
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
presentation = "app";
|
||||
}
|
||||
if (presentation && trace.at(-1) !== presentation) {
|
||||
trace.push(presentation);
|
||||
}
|
||||
};
|
||||
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordPresentation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
@@ -114,64 +160,46 @@ async function expectWorkspaceLocation(
|
||||
});
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(
|
||||
sockets.map((ws) =>
|
||||
ws
|
||||
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
agentId: "notification-agent",
|
||||
};
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: target.serverId,
|
||||
label: "Notification Host",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.evaluate((data) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: { data: { ...data, reason: "finished" } },
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, target);
|
||||
|
||||
await expectAppRoute(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -294,7 +322,9 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
@@ -309,6 +339,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
desktopSettingsDelayMs: 250,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
@@ -316,14 +347,16 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
|
||||
await observeStartupPresentation(page);
|
||||
await daemonGate.drop();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
await waitForDesktopDaemonStartRequest(page);
|
||||
daemonGate.restore();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
await workspace.cleanup();
|
||||
@@ -429,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -443,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -36,7 +36,52 @@ async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<str
|
||||
return terminal.id;
|
||||
}
|
||||
|
||||
async function cleanupTerminal(
|
||||
workspace: SeededWorkspace,
|
||||
terminalId: string | null,
|
||||
): Promise<void> {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
async function readClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByTestId(
|
||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
||||
);
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(terminalId);
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -74,10 +119,7 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { restartTestDaemon } from "./helpers/daemon-restart";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -11,11 +12,12 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore after daemon restart", () => {
|
||||
const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
let daemon: IsolatedHostDaemon;
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
@@ -25,8 +27,9 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
daemon = await startIsolatedHostDaemon(serverId);
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
tempRepo = await createTempGitRepo("wt-restart-");
|
||||
});
|
||||
|
||||
@@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedBrowser(page: Page) {
|
||||
const nowIso = new Date().toISOString();
|
||||
await page.addInitScript(
|
||||
({ host, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{
|
||||
host: buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
||||
// worktree workspace is displayed under the same name. These are the values
|
||||
// the History table cells must show after restore — never "main".
|
||||
@@ -76,14 +99,16 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
||||
// Restart this spec's daemon on the same home and port so it rebuilds all
|
||||
// workspace/agent links from persisted state without replacing the shared
|
||||
// Playwright daemon owned by global setup.
|
||||
await client.close().catch(() => undefined);
|
||||
await worktreeClient.close().catch(() => undefined);
|
||||
await restartTestDaemon();
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
await daemon.restart();
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
|
||||
await seedBrowser(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
|
||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
async function createArchivedMissingWorktree(
|
||||
prefix: string,
|
||||
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||
) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
}
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
if (options.keepAgentsArchived) {
|
||||
for (const archivedAgent of agents) {
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||
.not.toBeNull();
|
||||
}
|
||||
} else {
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
}
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
return { agent, agents, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
});
|
||||
const [firstAgent, secondAgent] = agents;
|
||||
if (!firstAgent || !secondAgent) {
|
||||
throw new Error("Expected two archived agents");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
@@ -249,7 +318,7 @@ test.describe("Worktree restore", () => {
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
"The source repository needed to restore this worktree no longer exists.",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "production",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"resourceClass": "large",
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -32,6 +32,11 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -46,6 +51,7 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
|
||||
@@ -11,6 +11,51 @@ const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||
def paseoAbiVersionCodes = [
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
"x86": 3,
|
||||
"x86_64": 4,
|
||||
]
|
||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||
.toString()
|
||||
.split(",")
|
||||
.collect { it.trim() }
|
||||
.findAll { !it.isEmpty() }
|
||||
|
||||
if (paseoArchitectures.size() == 1) {
|
||||
def paseoAbi = paseoArchitectures[0]
|
||||
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
|
||||
if (paseoAbiVersionCode == null) {
|
||||
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
|
||||
}
|
||||
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
|
||||
}
|
||||
`;
|
||||
|
||||
function configureFdroidAppBuildGradle(contents) {
|
||||
let configuredContents = contents;
|
||||
|
||||
if (!configuredContents.includes("dependenciesInfo {")) {
|
||||
const androidBlock = "android {";
|
||||
if (!configuredContents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
configuredContents = configuredContents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
|
||||
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
|
||||
}
|
||||
|
||||
return configuredContents;
|
||||
}
|
||||
|
||||
function withFdroidAutolinking(config) {
|
||||
config = withDangerousMod(config, [
|
||||
"android",
|
||||
@@ -65,19 +110,7 @@ function withFdroidAutolinking(config) {
|
||||
});
|
||||
|
||||
return withAppBuildGradle(config, (modConfig) => {
|
||||
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const androidBlock = "android {";
|
||||
if (!modConfig.modResults.contents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
addProjectMethodEmptyText,
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("hides every mutating method when the host lacks stable project identity", () => {
|
||||
const outdatedHost = { ...HOST, canAddProject: false };
|
||||
|
||||
expect(buildAddProjectMethods(outdatedHost)).toEqual([]);
|
||||
expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project.");
|
||||
});
|
||||
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
|
||||
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
|
||||
}
|
||||
|
||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||
if (!host.canAddProject) return [];
|
||||
const options: AddProjectMethodOption[] = [];
|
||||
if (host.canAddProject) {
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
if (host.canBrowse) {
|
||||
options.push({
|
||||
id: "browse",
|
||||
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
|
||||
return options;
|
||||
}
|
||||
|
||||
export function addProjectMethodEmptyText(host: AddProjectHost | null): string {
|
||||
return host?.canAddProject === false
|
||||
? "Update the host to use Add Project."
|
||||
: "No matching options";
|
||||
}
|
||||
|
||||
function githubMethodDescription(host: AddProjectHost): string {
|
||||
if (!host.canCloneGithubRepositories) {
|
||||
return "Update this host to clone GitHub repositories";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
@@ -126,10 +126,15 @@ function createDriverHarness(input?: {
|
||||
measurementState,
|
||||
nearBottom: input?.isNearBottom ?? true,
|
||||
};
|
||||
const scrollToBottom = vi.fn(() => {
|
||||
const scrollAttempts: boolean[] = [];
|
||||
let scrollToBottomBehavior = () => {
|
||||
context.nearBottom = true;
|
||||
context.measurementState.offsetY = 720;
|
||||
});
|
||||
};
|
||||
const scrollToBottom = (animated: boolean) => {
|
||||
scrollAttempts.push(animated);
|
||||
scrollToBottomBehavior();
|
||||
};
|
||||
const modeChanges: BottomAnchorMode[] = [];
|
||||
const driver = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => context.agentId,
|
||||
@@ -150,7 +155,10 @@ function createDriverHarness(input?: {
|
||||
context,
|
||||
driver,
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
scrollAttempts,
|
||||
setScrollToBottomBehavior(next: () => void) {
|
||||
scrollToBottomBehavior = next;
|
||||
},
|
||||
modeChanges,
|
||||
};
|
||||
}
|
||||
@@ -210,7 +218,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: "waiting_for_history_readiness",
|
||||
@@ -229,7 +237,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
@@ -237,6 +245,97 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "route:agent-1:initial-entry",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a blocked route anchor when layout moves after drag release", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a user scroll away supersede a blocked route anchor", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
});
|
||||
harness.driver.endUserScroll({ isNearBottom: false });
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "detached",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses sticky maintenance while detached", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
@@ -254,7 +353,74 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 1,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.endUserScroll({ isNearBottom: false });
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
});
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
});
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: -48,
|
||||
});
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||
@@ -271,7 +437,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
expect(harness.modeChanges).toContain("detached");
|
||||
expect(harness.modeChanges).toContain("sticky-bottom");
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
@@ -292,7 +458,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
||||
@@ -313,7 +479,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_measurable_viewport",
|
||||
pendingRequest: {
|
||||
@@ -326,7 +492,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -338,7 +504,7 @@ describe("bottom anchor controller driver", () => {
|
||||
},
|
||||
isNearBottom: false,
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -348,16 +514,16 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -375,7 +541,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 13476;
|
||||
});
|
||||
|
||||
@@ -386,7 +552,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.measurementState.contentHeight = 14804;
|
||||
harness.context.nearBottom = false;
|
||||
@@ -409,7 +575,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_post_layout_verification",
|
||||
pendingRequest: {
|
||||
@@ -435,7 +601,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = Math.max(
|
||||
0,
|
||||
harness.context.measurementState.contentHeight -
|
||||
@@ -471,7 +637,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
|
||||
reason: "resume",
|
||||
});
|
||||
@@ -480,7 +646,7 @@ describe("bottom anchor controller driver", () => {
|
||||
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 720;
|
||||
});
|
||||
|
||||
@@ -492,7 +658,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -523,7 +689,7 @@ describe("bottom anchor controller driver", () => {
|
||||
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 900;
|
||||
});
|
||||
|
||||
@@ -533,7 +699,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -577,7 +743,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: null,
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -588,7 +754,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentHeight: 1348,
|
||||
});
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
@@ -625,14 +791,14 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
|
||||
@@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver {
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
endUserScroll: (params: { isNearBottom: boolean }) => void;
|
||||
detachByUser: () => void;
|
||||
handleViewportMetricsChange: (params: {
|
||||
previousViewportWidth: number;
|
||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
let isUserScrollActive = false;
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
@@ -411,10 +414,14 @@ function createBottomAnchorControllerDriver(
|
||||
| "viewport_change"
|
||||
| "content_size_change"
|
||||
| "scroll_near_bottom_change"
|
||||
| "user_scroll_end"
|
||||
| "history_readiness_change"
|
||||
| "manual_reevaluate"
|
||||
| "retry_scroll",
|
||||
) => {
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (attemptHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -481,6 +488,7 @@ function createBottomAnchorControllerDriver(
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
@@ -497,6 +505,38 @@ function createBottomAnchorControllerDriver(
|
||||
requestLocalAnchor(request) {
|
||||
createRequest(request);
|
||||
},
|
||||
beginUserScroll() {
|
||||
isUserScrollActive = true;
|
||||
cancelPendingAttempt();
|
||||
},
|
||||
endUserScroll(params) {
|
||||
isUserScrollActive = false;
|
||||
if (params.isNearBottom) {
|
||||
if (mode === "detached") {
|
||||
setModeInternal("sticky-bottom");
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
if (pendingRequest) {
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!input.isNearBottom() ||
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
||||
) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementVerified();
|
||||
return;
|
||||
}
|
||||
if (mode === "sticky-bottom") {
|
||||
this.detachByUser();
|
||||
}
|
||||
},
|
||||
detachByUser() {
|
||||
if (mode === "detached") {
|
||||
return;
|
||||
@@ -511,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
@@ -529,6 +572,9 @@ function createBottomAnchorControllerDriver(
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
@@ -558,6 +604,9 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
@@ -571,6 +620,9 @@ function createBottomAnchorControllerDriver(
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
@@ -737,6 +789,12 @@ export function useBottomAnchorController(input: {
|
||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||
driverRef.current?.requestLocalAnchor(request);
|
||||
},
|
||||
beginUserScroll() {
|
||||
driverRef.current?.beginUserScroll();
|
||||
},
|
||||
endUserScroll(params: { isNearBottom: boolean }) {
|
||||
driverRef.current?.endUserScroll(params);
|
||||
},
|
||||
detachByUser() {
|
||||
driverRef.current?.detachByUser();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const visibleHistoryStart = {
|
||||
distanceFromHistoryStart: 0,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "epoch-1:20",
|
||||
};
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("loads once for each authoritative history cursor", () => {
|
||||
const initial = createHistoryStartPaginationState();
|
||||
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
const nextPage = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows the same revision again after the user leaves the history edge", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const away = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 200,
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
|
||||
|
||||
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
|
||||
});
|
||||
|
||||
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(first.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal file
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
requestedProgressKey: string | null;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { requestedProgressKey: null };
|
||||
}
|
||||
|
||||
export function rearmHistoryStartPagination(
|
||||
_state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return createHistoryStartPaginationState();
|
||||
}
|
||||
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: {
|
||||
distanceFromHistoryStart: number;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
},
|
||||
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
|
||||
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
return { state: createHistoryStartPaginationState(), shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.requestedProgressKey === input.progressKey) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
state: { requestedProgressKey: input.progressKey },
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
type ScrollKeyboardDismissEvent,
|
||||
type ScrollKeyboardDismissGesture,
|
||||
} from "./model";
|
||||
|
||||
interface Point {
|
||||
ts: number;
|
||||
y: number;
|
||||
nativeTs?: number | null;
|
||||
}
|
||||
|
||||
function event(point: Point): ScrollKeyboardDismissEvent {
|
||||
return {
|
||||
timeStamp: point.ts,
|
||||
nativeEvent: {
|
||||
contentOffset: { y: point.y },
|
||||
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
|
||||
return points.reduce(
|
||||
(gesture, point) => recordScroll(gesture, event(point)),
|
||||
beginDrag(event(start)),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
|
||||
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
|
||||
}
|
||||
|
||||
describe("scroll keyboard dismissal", () => {
|
||||
it("keeps the keyboard up for a slow read-scroll", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 8 },
|
||||
{ ts: 1080, y: 16 },
|
||||
{ ts: 1120, y: 24 },
|
||||
],
|
||||
{ ts: 1160, y: 32 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses for an upward flick", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a fast-but-decelerating drag below the release threshold", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1100, y: 500 },
|
||||
{ ts: 1200, y: 590 },
|
||||
{ ts: 1270, y: 598 },
|
||||
],
|
||||
{ ts: 1300, y: 600 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the whole drag when the gesture is too short to sample", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("steps back a sample when release lands immediately after one", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 120 },
|
||||
{ ts: 1075, y: 225 },
|
||||
],
|
||||
{ ts: 1080, y: 240 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when release has no measurable time span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scroll events that arrive before the minimum sample span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 5000, nativeTs: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 5001, nativeTs: 1200, y: 40 },
|
||||
{ ts: 5002, nativeTs: 1400, y: 80 },
|
||||
],
|
||||
{ ts: 5003, nativeTs: 1600, y: 120 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to synthetic event time when native time is absent", () => {
|
||||
expect(
|
||||
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
|
||||
ts: 1080,
|
||||
nativeTs: null,
|
||||
y: 240,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll and release events without a matching drag", () => {
|
||||
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
|
||||
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to idle after release", () => {
|
||||
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
|
||||
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
});
|
||||
});
|
||||
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pure state machine for the chat history's flick-to-dismiss gesture.
|
||||
*
|
||||
* Native keyboardDismissMode cannot express this interaction: "on-drag"
|
||||
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
|
||||
* inverted list. We therefore classify the list's existing scroll gesture at
|
||||
* release instead of introducing a competing pan recognizer.
|
||||
*/
|
||||
|
||||
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
|
||||
const RELEASE_SAMPLE_MIN_MS = 30;
|
||||
|
||||
/**
|
||||
* The subset of a native scroll event used by the classifier. React Native's
|
||||
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
|
||||
*/
|
||||
export interface ScrollKeyboardDismissEvent {
|
||||
timeStamp: number;
|
||||
nativeEvent: {
|
||||
contentOffset: { y: number };
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DragSamples {
|
||||
startTs: number;
|
||||
startY: number;
|
||||
sampleTs: number;
|
||||
sampleY: number;
|
||||
previousSampleTs: number;
|
||||
previousSampleY: number;
|
||||
}
|
||||
|
||||
export type ScrollKeyboardDismissGesture =
|
||||
| { phase: "idle" }
|
||||
| { phase: "dragging"; samples: DragSamples };
|
||||
|
||||
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
|
||||
const nativeTimestamp = event.nativeEvent.timestamp;
|
||||
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
|
||||
return nativeTimestamp;
|
||||
}
|
||||
return event.timeStamp;
|
||||
}
|
||||
|
||||
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: timestamp,
|
||||
startY: offsetY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: offsetY,
|
||||
previousSampleTs: 0,
|
||||
previousSampleY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordScroll(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): ScrollKeyboardDismissGesture {
|
||||
if (gesture.phase === "idle") {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const { samples } = gesture;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: samples.startTs,
|
||||
startY: samples.startY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: event.nativeEvent.contentOffset.y,
|
||||
previousSampleTs: samples.sampleTs,
|
||||
previousSampleY: samples.sampleY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseDrag(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
|
||||
if (gesture.phase === "idle") {
|
||||
return { gesture, shouldDismiss: false };
|
||||
}
|
||||
|
||||
const releaseTs = resolveEventTimeMs(event);
|
||||
const releaseY = event.nativeEvent.contentOffset.y;
|
||||
const { samples } = gesture;
|
||||
|
||||
// The release event carries the gesture's true endpoint. The final onScroll
|
||||
// event can still be stale when a short flick lands.
|
||||
let spanStartTs = samples.startTs;
|
||||
let spanStartY = samples.startY;
|
||||
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
|
||||
spanStartTs = samples.sampleTs;
|
||||
spanStartY = samples.sampleY;
|
||||
} else if (samples.previousSampleTs > 0) {
|
||||
spanStartTs = samples.previousSampleTs;
|
||||
spanStartY = samples.previousSampleY;
|
||||
}
|
||||
|
||||
const spanDurationMs = releaseTs - spanStartTs;
|
||||
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
|
||||
|
||||
return {
|
||||
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
TextInput,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
} from "./model";
|
||||
|
||||
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
|
||||
|
||||
/**
|
||||
* Owns the chat history's flick-to-dismiss behavior. The native stream only
|
||||
* forwards the FlatList scroll lifecycle; removing this hook and those three
|
||||
* calls removes the feature completely.
|
||||
*/
|
||||
export function useScrollKeyboardDismiss() {
|
||||
const { shift } = useKeyboardShift();
|
||||
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
|
||||
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = beginDrag(event);
|
||||
});
|
||||
|
||||
const onScroll = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = recordScroll(gestureRef.current, event);
|
||||
});
|
||||
|
||||
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
|
||||
const release = releaseDrag(gestureRef.current, event);
|
||||
gestureRef.current = release.gesture;
|
||||
|
||||
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
|
||||
// second calculation on JS, this prevents a hardware keyboard's focused
|
||||
// composer from being blurred when no software keyboard occupies space.
|
||||
if (!release.shouldDismiss || shift.value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep blur and dismiss paired: this exact sequence was validated on a
|
||||
// physical Android device to clear both input focus and the IME inset.
|
||||
const focusedInput = TextInput.State.currentlyFocusedInput();
|
||||
if (focusedInput) {
|
||||
TextInput.State.blurTextInput(focusedInput);
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
});
|
||||
|
||||
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
|
||||
}
|
||||
@@ -9,30 +9,50 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./strategy";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const historyStartSlotStyle: ViewStyle = {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 32,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
@@ -71,6 +91,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
@@ -88,10 +109,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: null as string | null,
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
@@ -120,6 +145,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
@@ -128,6 +170,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPendingUserScrollEnd = useCallback(() => {
|
||||
if (userScrollEndFrameIdRef.current !== null) {
|
||||
cancelAnimationFrame(userScrollEndFrameIdRef.current);
|
||||
userScrollEndFrameIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markNativeViewportSettling = useCallback(() => {
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(true);
|
||||
@@ -189,6 +238,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
scrollToBottom,
|
||||
});
|
||||
// Android's maintainVisibleContentPosition ignores the list inversion transform and
|
||||
// fights the controller's offset-zero correction while the live header grows.
|
||||
const maintainVisibleContentPosition =
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -201,16 +256,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -259,10 +319,25 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
};
|
||||
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
||||
|
||||
const isScrollEventNearBottom = useStableEvent(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
return isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: contentOffset.y,
|
||||
threshold: 32,
|
||||
contentHeight: contentSize.height,
|
||||
viewportHeight: layoutMeasurement.height,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
scrollKeyboardDismiss.onScroll(event);
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
@@ -273,28 +348,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
|
||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: contentOffset.y,
|
||||
threshold: 32,
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
const nearBottom = isScrollEventNearBottom(event);
|
||||
onNearBottomChange(nearBottom);
|
||||
|
||||
const distanceFromOldestEdge =
|
||||
streamViewportMetricsRef.current.contentHeight -
|
||||
streamViewportMetricsRef.current.viewportHeight -
|
||||
contentOffset.y;
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
|
||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||
if (
|
||||
!isUserScrollActiveRef.current &&
|
||||
programmaticScrollEventBudgetRef.current > 0 &&
|
||||
contentOffset.y <= 8
|
||||
) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
@@ -305,6 +368,51 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
// Defer drag end so momentum can take ownership, but capture the terminal
|
||||
// gesture position now because layout may move the viewport in the meantime.
|
||||
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const isNearBottom = isScrollEventNearBottom(event);
|
||||
scrollKeyboardDismiss.onScrollEndDrag(event);
|
||||
|
||||
clearPendingUserScrollEnd();
|
||||
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||
userScrollEndFrameIdRef.current = null;
|
||||
isUserScrollActiveRef.current = false;
|
||||
bottomAnchorController.endUserScroll({ isNearBottom });
|
||||
});
|
||||
});
|
||||
|
||||
const handleMomentumScrollBegin = useStableEvent(() => {
|
||||
clearPendingUserScrollEnd();
|
||||
});
|
||||
|
||||
const handleMomentumScrollEnd = useStableEvent(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
// Android can emit momentum-end after a programmatic anchor correction.
|
||||
// Only momentum that still owns the user gesture may settle scroll intent.
|
||||
if (!isUserScrollActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const isNearBottom = isScrollEventNearBottom(event);
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = false;
|
||||
bottomAnchorController.endUserScroll({ isNearBottom });
|
||||
},
|
||||
);
|
||||
|
||||
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||
@@ -329,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
previousViewportHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
|
||||
@@ -344,8 +453,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
previousContentHeight,
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
|
||||
|
||||
const renderItem = useStableEvent(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
|
||||
const rendered = renderHistoryMountedRow(item, index, historyItems);
|
||||
@@ -385,15 +499,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
]);
|
||||
|
||||
const historyFooterContent = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
<View
|
||||
style={historyStartSlotStyle}
|
||||
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
|
||||
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
@@ -412,9 +531,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
updateCellsBatchingPeriod={0}
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -176,6 +177,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -199,6 +201,90 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps bottom anchoring through subpixel browser rounding", () => {
|
||||
const scrollTo = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4959.1708984375,
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4960.5,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4967,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires near-history-start when the user scrolls near the top", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
@@ -231,6 +317,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -240,10 +327,6 @@ describe("createWebStreamStrategy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
@@ -252,11 +335,109 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
scrollContainer?.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
|
||||
HTMLElement.prototype.scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
@@ -295,6 +476,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -402,6 +584,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -498,6 +681,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -596,6 +780,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
|
||||
@@ -8,11 +8,19 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
@@ -22,9 +30,14 @@ type ScrollBehaviorLike = "auto" | "smooth";
|
||||
|
||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -88,7 +101,8 @@ function getScrollContainerDistanceFromBottom(
|
||||
function isScrollContainerOverscrolledPastBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): boolean {
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
|
||||
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
||||
}
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
@@ -105,6 +119,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
@@ -131,6 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -171,6 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [rowVirtualizer]);
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
const bottomAnchorSettled =
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -229,8 +264,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollElementToBottom(scrollContainer, behavior);
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||
evaluateHistoryStart();
|
||||
},
|
||||
[onNearBottomChange],
|
||||
[evaluateHistoryStart, onNearBottomChange],
|
||||
);
|
||||
|
||||
const scheduleStickToBottom = useCallback(() => {
|
||||
@@ -295,24 +331,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
updateScrollMetrics();
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
currentScrollTop <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
|
||||
evaluateHistoryStart();
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
};
|
||||
}, [props.agentId]);
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -368,7 +400,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -384,8 +421,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -398,7 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scheduleStickToBottom, updateScrollMetrics]);
|
||||
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -408,8 +447,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
};
|
||||
const handlePointerDown = () => {
|
||||
@@ -432,8 +477,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current;
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -462,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
}, [cancelPendingStickToBottom, handleDomScroll]);
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -544,15 +595,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
const historyStartSlot = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
<div
|
||||
style={historyStartSlotStyle}
|
||||
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface StreamRenderInput {
|
||||
onNearHistoryStart: () => void;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
scrollEnabled: boolean;
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
@@ -247,6 +247,7 @@ export interface AgentStreamViewProps {
|
||||
historyPagination?: {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
progressKey: string | null;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
}
|
||||
@@ -388,10 +389,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
|
||||
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
progressKey: historyPagination.progressKey,
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
@@ -876,7 +878,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = context.status === "running";
|
||||
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
@@ -1029,6 +1031,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
@@ -1138,6 +1141,7 @@ function historyPaginationPropsEqual(
|
||||
return (
|
||||
left?.hasOlder === right?.hasOlder &&
|
||||
left?.isLoadingOlder === right?.isLoadingOlder &&
|
||||
left?.progressKey === right?.progressKey &&
|
||||
left?.onLoadOlder === right?.onLoadOlder
|
||||
);
|
||||
}
|
||||
@@ -1194,7 +1198,7 @@ function ToolCallSlot({
|
||||
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
const ThemedXIcon = withUnistyles(X);
|
||||
|
||||
@@ -1234,12 +1238,14 @@ function PermissionActionButton({
|
||||
onPress,
|
||||
}: PermissionActionButtonProps) {
|
||||
const handlePress = useCallback(() => onPress(action), [onPress, action]);
|
||||
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
|
||||
const optionTextStyle = isPrimary
|
||||
? [permissionStyles.optionText, permissionStyles.optionTextPrimary]
|
||||
: permissionStyles.optionText;
|
||||
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
{isRespondingAction ? (
|
||||
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} uniProps={colorMapping} />
|
||||
@@ -1622,8 +1628,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
gapBelow: number;
|
||||
children: ReactNode;
|
||||
|
||||
@@ -21,7 +21,8 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -31,6 +32,7 @@ import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
@@ -59,13 +61,13 @@ import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
@@ -146,9 +148,10 @@ function PushNotificationRouter() {
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const workspaceId = target.workspaceId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
if (serverId && workspaceId && agentId) {
|
||||
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -336,7 +339,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -375,11 +377,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
}, []);
|
||||
|
||||
const splashError =
|
||||
@@ -553,6 +551,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
@@ -570,7 +570,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
return content;
|
||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
@@ -912,6 +912,7 @@ function AppShell() {
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AgentNavigationListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
const redirectedRef = useRef(false);
|
||||
const handledNavigationRef = useRef<string | null>(null);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const hosts = useHosts();
|
||||
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
|
||||
const client = runtimeSnapshot?.client ?? null;
|
||||
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
const lookupQuery = useFetchQuery({
|
||||
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Target host client is unavailable");
|
||||
}
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent?.workspaceId ?? null;
|
||||
},
|
||||
enabled: shouldLookupAgent,
|
||||
retry: false,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const lookup = useMemo<AgentRouteLookup>(() => {
|
||||
if (!shouldLookupAgent) {
|
||||
return { kind: "idle" };
|
||||
}
|
||||
if (lookupQuery.isFetching) {
|
||||
return { kind: "fetching" };
|
||||
}
|
||||
if (lookupQuery.isError) {
|
||||
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
|
||||
}
|
||||
if (lookupQuery.isSuccess) {
|
||||
return { kind: "found", workspaceId: lookupQuery.data };
|
||||
}
|
||||
return { kind: "fetching" };
|
||||
}, [
|
||||
lookupQuery.data,
|
||||
lookupQuery.error,
|
||||
lookupQuery.isError,
|
||||
lookupQuery.isFetching,
|
||||
lookupQuery.isSuccess,
|
||||
shouldLookupAgent,
|
||||
]);
|
||||
const resolution = resolveAgentRoute({
|
||||
serverId,
|
||||
agentId,
|
||||
cachedWorkspaceId: agentWorkspaceId,
|
||||
connectionStatus,
|
||||
lookup,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as Href);
|
||||
let navigationKey: string | null = null;
|
||||
if (resolution.kind === "invalid") {
|
||||
navigationKey = "invalid";
|
||||
} else if (resolution.kind === "resolved") {
|
||||
navigationKey = `workspace:${resolution.workspaceId}`;
|
||||
} else if (resolution.kind === "notFound") {
|
||||
navigationKey = "not-found";
|
||||
}
|
||||
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||
return;
|
||||
}
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
return;
|
||||
}
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
void lookupQuery.refetch();
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
if (serverId) {
|
||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
}
|
||||
}, [lookupQuery, resolution.kind, serverId]);
|
||||
const handleManageHost = useCallback(() => {
|
||||
if (serverId) {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
}
|
||||
}, [router, serverId]);
|
||||
const handleBack = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId || !client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
if (
|
||||
resolution.kind === "waitingForHost" ||
|
||||
resolution.kind === "fetchingAgent" ||
|
||||
resolution.kind === "lookupError"
|
||||
) {
|
||||
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
|
||||
// reconnects, then resolve the workspace from the authoritative agent record.
|
||||
return (
|
||||
<AgentRouteResolutionView
|
||||
resolution={resolution}
|
||||
hostName={hostName}
|
||||
lastHostError={runtimeSnapshot?.lastError ?? null}
|
||||
onRetry={handleRetry}
|
||||
onManageHost={handleManageHost}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeck({
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: {
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -257,10 +257,10 @@ export default function PairScanScreen() {
|
||||
/>
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={CORNER_TL_STYLE} />
|
||||
<View style={CORNER_TR_STYLE} />
|
||||
<View style={CORNER_BL_STYLE} />
|
||||
<View style={CORNER_BR_STYLE} />
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
</View>
|
||||
@@ -272,7 +272,3 @@ export default function PairScanScreen() {
|
||||
}
|
||||
|
||||
const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] };
|
||||
const CORNER_TL_STYLE = [styles.corner, styles.cornerTL];
|
||||
const CORNER_TR_STYLE = [styles.corner, styles.cornerTR];
|
||||
const CORNER_BL_STYLE = [styles.corner, styles.cornerBL];
|
||||
const CORNER_BR_STYLE = [styles.corner, styles.cornerBR];
|
||||
|
||||
@@ -58,8 +58,6 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
nova: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1"/>\n <circle cx="8" cy="8" r="4.5" fill="none" stroke="currentColor" stroke-width="0.3" opacity="0.5"/>\n <line x1="8" y1="8" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>\n <polygon points="14.5,14.5 12.3,13.5 13.5,12.3" fill="currentColor"/>\n <line x1="8" y1="8" x2="2.5" y2="2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>\n <polygon points="1.5,1.5 3.7,2.5 2.5,3.7" fill="currentColor"/>\n <circle cx="8" cy="8" r="1.3" fill="currentColor"/>\n <circle cx="8" cy="8" r="0.6" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/>\n</svg>\n',
|
||||
opencode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="currentColor"/>\n</svg>\n',
|
||||
"pi-acp":
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M1 1H11.7692V7.9999H8.17942V11.4999H4.58982V15H1V1ZM4.58982 4.50005V7.9999H8.17942V4.50005H4.58982Z" fill="currentColor"/>\n<path d="M11.7692 7.46154H15V15H11.7692V7.46154Z" fill="currentColor"/>\n</svg>\n',
|
||||
poolside:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M2.63653 5.50522L2.18735 5.28559C2.09852 5.46726 2.12787 5.68456 2.26172 5.83616C2.39558 5.98775 2.60757 6.04379 2.79884 5.97814L2.63653 5.50522ZM5.83578 6.94285L5.3732 7.13265L5.5645 7.59886L6.02919 7.40392L5.83578 6.94285ZM10.7441 9.44034L10.2614 9.57083L10.393 10.0573L10.8784 9.92198L10.7441 9.44034ZM2.5 8.24963C2.4998 7.97349 2.27577 7.74979 1.99963 7.75C1.72349 7.75021 1.4998 7.97423 1.5 8.25037L2.5 8.24963ZM13.4143 10.7751C12.0197 13.6273 8.57701 14.8089 5.72485 13.4143L5.28559 14.3126C8.63389 15.9498 12.6754 14.5627 14.3126 11.2144L13.4143 10.7751ZM3.08571 5.72485C4.48031 2.87269 7.92299 1.69111 10.7751 3.08571L11.2144 2.18735C7.86611 0.550151 3.82455 1.93728 2.18735 5.28559L3.08571 5.72485ZM10.7751 3.08571C13.6273 4.48031 14.8089 7.92299 13.4143 10.7751L14.3126 11.2144C15.9498 7.86611 14.5627 3.82455 11.2144 2.18735L10.7751 3.08571ZM2.79884 5.97814C3.02201 5.90155 3.54835 5.84926 4.09141 6.00587C4.61256 6.15616 5.10736 6.48476 5.3732 7.13265L6.29835 6.75304C5.89213 5.76303 5.11508 5.26032 4.3685 5.04503C3.64385 4.83605 2.9066 4.8839 2.47422 5.0323L2.79884 5.97814ZM6.02919 7.40392C6.25314 7.30998 6.55771 7.25841 6.85281 7.36034C7.13062 7.45631 7.49214 7.72014 7.78743 8.4398L8.71257 8.0602C8.3358 7.14195 7.79046 6.62626 7.17931 6.41515C6.58544 6.21001 6.01893 6.32381 5.64236 6.48177L6.02919 7.40392ZM6.26827 7.19375C6.80493 6.26869 8.18817 4.18684 11.1653 3.10654L10.8242 2.16652C7.53166 3.36127 5.99463 5.67263 5.40329 6.69195L6.26827 7.19375ZM14.3364 10.8325C14.1845 10.39 13.7564 9.79897 13.14 9.37995C12.504 8.94759 11.6302 8.67426 10.6099 8.9587L10.8784 9.92198C11.5634 9.73102 12.1368 9.90713 12.5779 10.207C13.0385 10.5201 13.3174 10.9441 13.3905 11.1571L14.3364 10.8325ZM11.2268 9.30984C11.1236 8.92833 10.8783 8.39265 10.3638 8.02453C9.82769 7.6409 9.08331 7.49864 8.11574 7.76836L8.38426 8.73164C9.1221 8.52595 9.53979 8.66454 9.78189 8.83777C10.0457 9.02651 10.1946 9.32371 10.2614 9.57083L11.2268 9.30984ZM11.216 9.60556C11.6225 8.44463 12.4244 5.47936 11.4712 2.48487L10.5183 2.78819C11.3726 5.47172 10.6545 8.18331 10.2722 9.27512L11.216 9.60556ZM7.80082 8.03037L5.05604 13.6438L5.9544 14.0831L8.69918 8.46963L7.80082 8.03037ZM5.72485 13.4143C3.68742 12.4181 2.50158 10.3763 2.5 8.24963L1.5 8.25037C1.50185 10.7441 2.89229 13.1424 5.28559 14.3126L5.72485 13.4143Z" fill="currentColor"/>\n</svg>\n',
|
||||
qoder:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isRasterImageMimeType,
|
||||
isRasterImagePath,
|
||||
RASTER_IMAGE_FILE_EXTENSIONS,
|
||||
resolveRasterImageMimeType,
|
||||
} from "./file-types";
|
||||
|
||||
describe("attachment file types", () => {
|
||||
@@ -37,4 +38,28 @@ describe("attachment file types", () => {
|
||||
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit raster MIME metadata before the filename", () => {
|
||||
expect(
|
||||
resolveRasterImageMimeType({ mimeType: "image/jpeg", path: "/tmp/screenshot.png" }),
|
||||
).toBe("image/jpeg");
|
||||
expect(
|
||||
resolveRasterImageMimeType({
|
||||
mimeType: "image/png; charset=binary",
|
||||
path: "/tmp/screenshot.jpg",
|
||||
}),
|
||||
).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uses the filename only when MIME metadata is absent", () => {
|
||||
expect(resolveRasterImageMimeType({ mimeType: "", path: "/tmp/screenshot.png" })).toBe(
|
||||
"image/png",
|
||||
);
|
||||
expect(
|
||||
resolveRasterImageMimeType({
|
||||
mimeType: "application/octet-stream",
|
||||
path: "/tmp/screenshot.png",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,18 +41,31 @@ export function getRasterImageMimeTypeFromPath(path: string): string | null {
|
||||
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
|
||||
}
|
||||
|
||||
export function resolveRasterImageMimeType(input: {
|
||||
mimeType?: string | null;
|
||||
path?: string | null;
|
||||
}): string | null {
|
||||
const suppliedMimeType = input.mimeType?.trim();
|
||||
if (suppliedMimeType) {
|
||||
const normalizedMimeType = suppliedMimeType.split(";", 1)[0]?.trim().toLowerCase();
|
||||
if (normalizedMimeType === "image/jpg") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
return normalizedMimeType && RASTER_IMAGE_MIME_TYPES.has(normalizedMimeType)
|
||||
? normalizedMimeType
|
||||
: null;
|
||||
}
|
||||
return input.path ? getRasterImageMimeTypeFromPath(input.path) : null;
|
||||
}
|
||||
|
||||
export function isRasterImagePath(path: string): boolean {
|
||||
return getRasterImageMimeTypeFromPath(path) !== null;
|
||||
}
|
||||
|
||||
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
|
||||
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized));
|
||||
return resolveRasterImageMimeType({ mimeType }) !== null;
|
||||
}
|
||||
|
||||
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
|
||||
if (isRasterImageMimeType(file.type)) {
|
||||
return true;
|
||||
}
|
||||
return file.type.trim().length === 0 && isRasterImagePath(file.name);
|
||||
return resolveRasterImageMimeType({ mimeType: file.type, path: file.name }) !== null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user