mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
149 Commits
v0.1.101
...
derive-des
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da | ||
|
|
b3eb77981a | ||
|
|
3e28b9b59e | ||
|
|
42d1a2e4e3 | ||
|
|
e98d8c7a36 | ||
|
|
9c220c2451 | ||
|
|
b28f291493 | ||
|
|
f226222638 | ||
|
|
6ce4e3f507 | ||
|
|
8d3a43e05d | ||
|
|
7cb0ccba60 | ||
|
|
f1a8e9a563 | ||
|
|
75b11ac93e | ||
|
|
8680bc2236 | ||
|
|
e67e25628b | ||
|
|
860fe92fdd | ||
|
|
ab62a023fd | ||
|
|
cc21262130 | ||
|
|
2a226b3872 | ||
|
|
a13fa4e614 | ||
|
|
ccd2bb2bf3 | ||
|
|
570e652496 | ||
|
|
6d55565d19 | ||
|
|
fec723c775 | ||
|
|
1bf50349a5 | ||
|
|
cda1ba43cc | ||
|
|
f22865df62 | ||
|
|
33ffeb4d42 | ||
|
|
6781521c8e | ||
|
|
4ca89f21f9 | ||
|
|
9430a897e8 | ||
|
|
b0088303fd | ||
|
|
2ba3ad53ab | ||
|
|
b2714ccd89 | ||
|
|
82993c2197 | ||
|
|
742d47fe71 | ||
|
|
441b935b73 | ||
|
|
b2ad903005 | ||
|
|
554e525186 | ||
|
|
71ce96b60f | ||
|
|
d9615c1e98 | ||
|
|
6fea8305a7 | ||
|
|
172de4fbc2 | ||
|
|
adc0c01782 | ||
|
|
3eed8c31a2 | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 | ||
|
|
2263469342 | ||
|
|
5a0ea3385e | ||
|
|
3708eddbcd | ||
|
|
f41dde8c72 | ||
|
|
d3e8f77914 | ||
|
|
48d07dedb5 | ||
|
|
31e9a210d0 | ||
|
|
bf5e3b47e7 | ||
|
|
b8b66816ca | ||
|
|
2b46caa20e | ||
|
|
20385bdb50 | ||
|
|
db03b1f3fd | ||
|
|
cb486c3a5a | ||
|
|
7dad7a377c | ||
|
|
e32c50f2d7 | ||
|
|
e3eb633582 | ||
|
|
beac4544fd | ||
|
|
c2271ff0ca | ||
|
|
f91806defe | ||
|
|
e9431517a0 | ||
|
|
4e28b0941b | ||
|
|
7c6152663e | ||
|
|
5fc53c576e | ||
|
|
8eb9640f28 | ||
|
|
0798f997c3 | ||
|
|
68a169b29d | ||
|
|
b571874852 | ||
|
|
ad803005c8 | ||
|
|
86775c18f3 | ||
|
|
cf2fccd01d | ||
|
|
811f52395e | ||
|
|
df36a958f8 | ||
|
|
111fdb81fd | ||
|
|
efd7ab3420 | ||
|
|
f94ec0e430 | ||
|
|
0614618d2f | ||
|
|
f31bc2b8b7 | ||
|
|
31d14ec8a7 | ||
|
|
a1afdfee51 | ||
|
|
b5559dc1de | ||
|
|
85acaceb16 | ||
|
|
e63a971968 | ||
|
|
b613bea9f6 | ||
|
|
57800a0f17 | ||
|
|
0d1c8db87d | ||
|
|
6a23bbd121 | ||
|
|
3288e1cfb9 | ||
|
|
cb212b4e5c | ||
|
|
4968969c87 | ||
|
|
96573d0bcc | ||
|
|
9b6aa99396 | ||
|
|
276c1f48f1 | ||
|
|
821d194fbe | ||
|
|
ab3ed56513 | ||
|
|
62bdb52eaa | ||
|
|
d7b24ab124 | ||
|
|
c7e27fdd5b | ||
|
|
fc52b2812c | ||
|
|
47a71631e8 | ||
|
|
696a9f0119 | ||
|
|
26a8a04651 | ||
|
|
a908384e5a | ||
|
|
24a1f1b8b9 | ||
|
|
65f1143e6a | ||
|
|
f275275074 | ||
|
|
5ef118ea33 | ||
|
|
8228bafc5d | ||
|
|
d6b946720e | ||
|
|
07680cdd69 | ||
|
|
1ce00dca1f | ||
|
|
28ea0914c5 | ||
|
|
6277ba1ff9 | ||
|
|
85322c5968 | ||
|
|
daf7042cd2 | ||
|
|
99643ad085 | ||
|
|
40265782d4 | ||
|
|
9ad49fca92 | ||
|
|
3fbd82664d | ||
|
|
14a91d889c | ||
|
|
2c9db5279c | ||
|
|
28c5e55bd9 | ||
|
|
862154541a | ||
|
|
8c3b709794 | ||
|
|
c5c2ace698 | ||
|
|
a49c658d1f | ||
|
|
1109e453bc | ||
|
|
4e2c06ec71 | ||
|
|
d4cdd4d749 | ||
|
|
3d6b4adc68 |
41
.dockerignore
Normal file
41
.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
||||
.git
|
||||
.debug.conversations
|
||||
.debug
|
||||
.dev
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
.paseo
|
||||
**/.paseo-provider-history
|
||||
.plans
|
||||
.tasks
|
||||
.valknut
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
PLAN.md
|
||||
valknut-report.html
|
||||
valknut-report.json
|
||||
.env*
|
||||
**/.env*
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
*.pem
|
||||
**/*.pem
|
||||
**/.secrets
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/.cache
|
||||
**/.expo
|
||||
**/test-results
|
||||
**/*.tsbuildinfo
|
||||
artifacts
|
||||
packages/app/android
|
||||
packages/desktop/release
|
||||
plan.*.log
|
||||
*.log
|
||||
CLAUDE.local.md
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -76,7 +76,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -113,7 +112,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
@@ -170,7 +168,6 @@ jobs:
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -202,7 +199,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -227,7 +223,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -241,6 +236,11 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -265,7 +265,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -280,7 +279,7 @@ jobs:
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -288,7 +287,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
|
||||
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -27,7 +27,6 @@ jobs:
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
189
.github/workflows/docker.yml
vendored
Normal file
189
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,189 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Expected source version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
description: "Publish the image to GHCR. Manual publishes require paseo_version."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- id: values
|
||||
env:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||
publish=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
install_version="${REF_NAME#v}"
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
|
||||
echo "::error::paseo_version is required for manual Docker publishes."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
fi
|
||||
|
||||
if [[ "${install_version}" == *-* ]]; then
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
fi
|
||||
|
||||
check_tag="${image}:check-${GITHUB_SHA::12}"
|
||||
publish_tags="${image}:${install_version}"
|
||||
if [[ "${publish_latest}" == "true" ]]; then
|
||||
publish_tags="${publish_tags}"$'\n'"${image}:latest"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.check_tag }}
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
|
||||
publish:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.publish_tags }}
|
||||
push: true
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,6 +84,7 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.104-beta.4 - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
|
||||
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
|
||||
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
||||
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
||||
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
||||
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
|
||||
|
||||
### Improved
|
||||
|
||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
||||
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
||||
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
||||
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
|
||||
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
|
||||
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -21,32 +21,36 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -71,6 +75,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
@@ -88,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- New fields: `.optional()` with a sensible default.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
||||
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
||||
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
Paseo is an opinionated product maintained by one person right now.
|
||||
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
|
||||
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
## Becoming a maintainer
|
||||
|
||||
Follow these rules if you want your PR to be merged:
|
||||
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
|
||||
|
||||
Here's the progression:
|
||||
|
||||
1. Get involved in the community: answer questions in Discord and on GitHub
|
||||
2. Triage bugs: replicate and help fix them
|
||||
3. Work on maintainer-approved features
|
||||
|
||||
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
|
||||
|
||||
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
|
||||
|
||||
## Pull requests
|
||||
|
||||
✅ Will be accepted
|
||||
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
@@ -18,15 +32,25 @@ Follow these rules if you want your PR to be merged:
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
Your PR will be closed if you do any of these:
|
||||
⛔️ Will be rejected
|
||||
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Add a feature or design change that wasn't discussed first
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
## Requesting features
|
||||
|
||||
If you need a feature implemented, create a Github issue or a thread in Discord.
|
||||
|
||||
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
|
||||
|
||||
## AI assistance
|
||||
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
Using AI to help write code is fine, but you must:
|
||||
|
||||
- Ensure your agents read the docs
|
||||
- Understand the code you submit
|
||||
- Review and test the code yourself
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -88,6 +88,21 @@ For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
### Docker
|
||||
|
||||
Run the Paseo daemon and self-hosted web UI in Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
@@ -154,6 +169,7 @@ npm run typecheck
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ Connected clients are trusted operators of the daemon user. File previews follow
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
`paseo` user by default. Mounted workspaces and credentials are still fully
|
||||
available to anything the agents run inside the container.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
17
docker/Dockerfile.agents.example
Normal file
17
docker/Dockerfile.agents.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Example child image that adds agent CLIs to the official Paseo image.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f docker/Dockerfile.agents.example -t paseo-with-agents .
|
||||
#
|
||||
# Then set `image: paseo-with-agents` in docker/docker-compose.example.yml.
|
||||
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
opencode-ai
|
||||
|
||||
# Leave the image user as root. The base entrypoint prepares mounted volumes,
|
||||
# then drops the daemon and launched agents to the non-root `paseo` user.
|
||||
30
docker/README.md
Normal file
30
docker/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Paseo Docker Image
|
||||
|
||||
This directory contains the official Paseo daemon image.
|
||||
|
||||
The image runs the daemon headless and serves the bundled web UI from the same
|
||||
HTTP origin. Start it, then open the daemon URL in a browser.
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open `http://localhost:6767`.
|
||||
|
||||
The base image intentionally does not bundle agent CLIs. Extend it with the
|
||||
agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security,
|
||||
agent auth, and troubleshooting notes.
|
||||
104
docker/base/Dockerfile
Normal file
104
docker/base/Dockerfile
Normal file
@@ -0,0 +1,104 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
if [ -n "${PASEO_VERSION:-}" ]; then \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
fi; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
PASEO_WEB_UI_ENABLED=true \
|
||||
PASEO_LOG_FORMAT=json \
|
||||
PASEO_LOG_LEVEL=info \
|
||||
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||
CODEX_HOME=/home/paseo/.codex \
|
||||
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||
node --check "$server_entry"
|
||||
|
||||
RUN set -eux; \
|
||||
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||
groupmod --new-name paseo "$existing_group"; \
|
||||
elif [ -z "$existing_group" ]; then \
|
||||
groupadd --gid 1000 paseo; \
|
||||
fi; \
|
||||
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||
elif [ -z "$existing_user" ]; then \
|
||||
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||
fi; \
|
||||
mkdir -p \
|
||||
/workspace \
|
||||
"$PASEO_HOME" \
|
||||
"$CLAUDE_CONFIG_DIR" \
|
||||
"$CODEX_HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$XDG_STATE_HOME" \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 6767
|
||||
VOLUME ["/home/paseo"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_HOME="/home/paseo"
|
||||
|
||||
: "${HOME:=$IMAGE_HOME}"
|
||||
: "${PASEO_HOME:=${HOME}/.paseo}"
|
||||
: "${PASEO_LISTEN:=0.0.0.0:6767}"
|
||||
: "${PASEO_WEB_UI_ENABLED:=true}"
|
||||
: "${PASEO_LOG_LEVEL:=info}"
|
||||
: "${PASEO_LOG_FORMAT:=json}"
|
||||
: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}"
|
||||
: "${CODEX_HOME:=${HOME}/.codex}"
|
||||
: "${XDG_CONFIG_HOME:=${HOME}/.config}"
|
||||
: "${XDG_DATA_HOME:=${HOME}/.local/share}"
|
||||
: "${XDG_STATE_HOME:=${HOME}/.local/state}"
|
||||
: "${XDG_CACHE_HOME:=${HOME}/.cache}"
|
||||
|
||||
export HOME
|
||||
export PASEO_HOME
|
||||
export PASEO_LISTEN
|
||||
export PASEO_WEB_UI_ENABLED
|
||||
export PASEO_LOG_LEVEL
|
||||
export PASEO_LOG_FORMAT
|
||||
export CLAUDE_CONFIG_DIR
|
||||
export CODEX_HOME
|
||||
export XDG_CONFIG_HOME
|
||||
export XDG_DATA_HOME
|
||||
export XDG_STATE_HOME
|
||||
export XDG_CACHE_HOME
|
||||
|
||||
ensure_dir() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
local owner
|
||||
owner="$(stat -c "%u" "$dir")"
|
||||
if [[ "$owner" == "0" ]]; then
|
||||
chown paseo:paseo "$dir"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dir "$HOME"
|
||||
ensure_dir "$PASEO_HOME"
|
||||
ensure_dir "$CLAUDE_CONFIG_DIR"
|
||||
ensure_dir "$CODEX_HOME"
|
||||
ensure_dir "$XDG_CONFIG_HOME"
|
||||
ensure_dir "$XDG_DATA_HOME"
|
||||
ensure_dir "$XDG_STATE_HOME"
|
||||
ensure_dir "$XDG_CACHE_HOME"
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo "$@"
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [[ -z "${PASEO_PASSWORD:-}" ]]; then
|
||||
{
|
||||
echo "[paseo] WARNING: PASEO_PASSWORD is not set."
|
||||
echo "[paseo] The daemon accepts unauthenticated control connections from any client that can reach it."
|
||||
echo "[paseo] Set PASEO_PASSWORD for any published port or network-reachable deployment."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
if [[ ! -f /etc/paseo-server-entry ]]; then
|
||||
echo "[paseo] FATAL: /etc/paseo-server-entry is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
entry="$(cat /etc/paseo-server-entry)"
|
||||
echo "[paseo] starting daemon on ${PASEO_LISTEN} with web UI ${PASEO_WEB_UI_ENABLED}"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo node "$entry"
|
||||
fi
|
||||
exec node "$entry"
|
||||
21
docker/docker-compose.example.yml
Normal file
21
docker/docker-compose.example.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Minimal Paseo daemon + web UI deployment.
|
||||
#
|
||||
# Open http://localhost:6767 after `docker compose up -d`.
|
||||
# For any network-reachable deployment, change PASEO_PASSWORD first.
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
container_name: paseo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
# Add DNS names you use to reach this container. IPs and localhost are
|
||||
# already allowed by default.
|
||||
# PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
volumes:
|
||||
# Persistent daemon state and agent credentials/config.
|
||||
- ./paseo-home:/home/paseo
|
||||
# Code visible to Paseo and the agents it launches.
|
||||
- ./workspace:/workspace
|
||||
@@ -50,6 +50,7 @@ The heart of Paseo. A Node.js process that:
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
@@ -137,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -168,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
69
docs/browser-capture-harness.md
Normal file
69
docs/browser-capture-harness.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Browser Capture Harness
|
||||
|
||||
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
||||
It validates the compositor behavior that unit tests cannot see:
|
||||
|
||||
- the resident automation `<webview>` starts in the production parking state;
|
||||
- the parked guest remains paintable and has a copyable viewport frame;
|
||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||
stacking changes;
|
||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||
by retrying until the frame appears;
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination.
|
||||
|
||||
Run it with the repo Electron:
|
||||
|
||||
```bash
|
||||
npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Run the browser automation fixture with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
||||
|
||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||
focus; a normal Electron app launch can still activate the app and steal focus.
|
||||
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
||||
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
||||
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
||||
the compositor only needs visible inactive windows, and harness runs must not steal
|
||||
focus from the person using the machine.
|
||||
|
||||
The harness writes PNG evidence and `results.json` to:
|
||||
|
||||
```text
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||
is usually saved as 2560x1600.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
||||
overlap at `left:0`, `top:0`.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
invalidates before each attempt, and retries known first-frame failures within the
|
||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
---
|
||||
|
||||
## Runtime PID Lock
|
||||
|
||||
**Path:** `$PASEO_HOME/paseo.pid`
|
||||
|
||||
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||
It is a runtime lock, not a durable settings file.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||
| `hostname` | `string` | Host that created the lock |
|
||||
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||
|
||||
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||
executable must be the desktop app executable or live inside the same desktop
|
||||
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||
it because an npm-installed CLI has no authoritative desktop install root.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
@@ -150,6 +174,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
@@ -163,7 +188,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -201,13 +231,17 @@ Set these to select OpenAI instead of local speech:
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -216,7 +250,7 @@ OpenAI voice can be configured under `providers.openai`:
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
|
||||
@@ -49,37 +49,11 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Expo Router layout ownership
|
||||
### Expo Router
|
||||
|
||||
Each layout owns only the routes directly inside its directory. In the root
|
||||
layout, register `h/[serverId]`; do not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
|
||||
routes with its own nested stack and relative screen names:
|
||||
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
|
||||
warns with `[Layout children]: No route named ...` when a layout registers
|
||||
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
|
||||
leave a nested index route mounted without its local dynamic params and render a
|
||||
blank screen.
|
||||
|
||||
Do not paper over missing required route params by reading global params in the
|
||||
leaf. Required dynamic params belong to the matched route. If
|
||||
`useLocalSearchParams()` misses one, fix the layout ownership.
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree. Put shared route policy in
|
||||
`src/navigation`, `src/utils`, or another non-route directory.
|
||||
|
||||
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
|
||||
workspace for that host after the workspace-selection store hydrates unless the
|
||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||
remembered workspace go to `open-project`.
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId`/
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
Route ownership, startup restore, and native blank-screen gotchas live in
|
||||
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
|
||||
startup routing, remembered workspace restore, or active workspace selection.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
@@ -98,6 +72,10 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
||||
|
||||
### React render profiling
|
||||
|
||||
The app has a gated React render profiler in
|
||||
@@ -200,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
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.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
@@ -236,6 +223,56 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
|
||||
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
||||
|
||||
Enable it for a running daemon with:
|
||||
|
||||
```bash
|
||||
paseo daemon start --web-ui
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"webUi": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
|
||||
|
||||
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
|
||||
|
||||
Build the artifact for packaging or measurement with:
|
||||
|
||||
```bash
|
||||
npm run build:daemon-web-ui
|
||||
```
|
||||
|
||||
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
|
||||
|
||||
Measured bundle size for a standard Expo web export:
|
||||
|
||||
- raw: 10.77 MiB
|
||||
- gzip: 2.55 MiB
|
||||
- brotli: 1.93 MiB
|
||||
|
||||
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
|
||||
|
||||
## Built workspace packages
|
||||
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
238
docs/docker.md
Normal file
238
docs/docker.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Running Paseo in Docker
|
||||
|
||||
Paseo publishes a container image for running the daemon on a server, VM, NAS,
|
||||
or homelab box. The image also serves the bundled browser web UI, so one
|
||||
container gives you both the daemon API and a self-hosted UI.
|
||||
|
||||
The image source lives in [`docker/`](../docker/).
|
||||
|
||||
## How it works
|
||||
|
||||
The official image:
|
||||
|
||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
- stores daemon state and agent credentials under `/home/paseo`
|
||||
- leaves agent CLIs out of the base image
|
||||
|
||||
Open the container's HTTP origin, for example `http://localhost:6767`, to load
|
||||
the web UI. The served app receives a same-origin connection hint and connects
|
||||
back to that daemon. Static UI files load without daemon auth; API and
|
||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:6767
|
||||
```
|
||||
|
||||
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
|
||||
daemon connection in the web UI or another Paseo client.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
|
||||
|
||||
```bash
|
||||
cp docker/docker-compose.example.yml docker-compose.yml
|
||||
$EDITOR docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
volumes:
|
||||
- ./paseo-home:/home/paseo
|
||||
- ./workspace:/workspace
|
||||
```
|
||||
|
||||
## Installing Agents
|
||||
|
||||
The base image does not preinstall Claude Code, Codex, OpenCode, Copilot, Pi, or
|
||||
other agent CLIs. That keeps the default image small and avoids coupling Paseo
|
||||
releases to third-party agent release cycles.
|
||||
|
||||
Create a child image for the agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
|
||||
```
|
||||
|
||||
Build it:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile -t paseo-with-agents .
|
||||
```
|
||||
|
||||
Then use `image: paseo-with-agents` in Compose.
|
||||
|
||||
Leave the child image user as root. The base entrypoint uses root only for
|
||||
first-run directory setup, then drops the daemon and launched agents to the
|
||||
non-root `paseo` user.
|
||||
|
||||
An example child image is in
|
||||
[`docker/Dockerfile.agents.example`](../docker/Dockerfile.agents.example).
|
||||
|
||||
You can also mount credentials from the host or run agent login once inside the
|
||||
container:
|
||||
|
||||
```bash
|
||||
docker exec -it --user paseo paseo codex
|
||||
docker exec -it --user paseo paseo claude
|
||||
```
|
||||
|
||||
Agent credentials and config persist in `/home/paseo`, alongside daemon state.
|
||||
Provider environment variables such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
|
||||
`OPENAI_BASE_URL`, or `ANTHROPIC_BASE_URL` can be passed through `docker run -e`
|
||||
or `compose.environment`; Paseo passes them to launched agents.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Purpose |
|
||||
| ------------- | ------------------------------------------------------------------------ |
|
||||
| `/home/paseo` | Paseo state under `.paseo` plus agent config such as `.codex`, `.claude` |
|
||||
| `/workspace` | Code that Paseo and launched agents can read and write |
|
||||
|
||||
The image defaults:
|
||||
|
||||
| Variable | Default |
|
||||
| -------------- | -------------------- |
|
||||
| `HOME` | `/home/paseo` |
|
||||
| `PASEO_HOME` | `/home/paseo/.paseo` |
|
||||
| `PASEO_LISTEN` | `0.0.0.0:6767` |
|
||||
|
||||
If you bind-mount host directories on Linux, make sure the container user can
|
||||
write them. The built-in `paseo` user has uid/gid `1000:1000`. For a different
|
||||
host uid/gid, either adjust ownership on the mounted directories or run the
|
||||
container with Docker's `--user` / Compose `user:` option.
|
||||
|
||||
## Reverse Proxies
|
||||
|
||||
When serving Paseo behind a reverse proxy, forward normal HTTP requests and
|
||||
WebSocket upgrades to the same daemon port.
|
||||
|
||||
Caddy example:
|
||||
|
||||
```caddy
|
||||
paseo.example.com {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
```
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name paseo.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6767;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you reach the daemon by DNS name, set `PASEO_HOSTNAMES` so host-header
|
||||
validation allows that name:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
```
|
||||
|
||||
IPs and `localhost` are allowed by default.
|
||||
|
||||
## Security
|
||||
|
||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||
want to expose the daemon port directly.
|
||||
- The container is the isolation boundary for agents. Agents can read and write
|
||||
whatever you mount into `/workspace` and whatever credentials you place in
|
||||
`/home/paseo`.
|
||||
- The bundled web UI static files are public on the daemon origin. The daemon
|
||||
API and WebSocket remain protected by password auth when configured.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
||||
```
|
||||
|
||||
To assert the source tree version while building:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
-f docker/base/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
non-publishing check. Stable `vX.Y.Z` tag pushes publish
|
||||
`ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`. Beta tags
|
||||
publish only the exact prerelease tag, such as
|
||||
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
|
||||
|
||||
To replace a Docker image in place without rebuilding desktop, APK, or EAS
|
||||
mobile release artifacts, dispatch the Docker workflow manually instead of
|
||||
pushing a `v*` release tag:
|
||||
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
||||
from the checked-out source tree and publishes only the exact prerelease image
|
||||
tag for prerelease versions.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The web UI loads but cannot connect**: if `PASEO_PASSWORD` is set, add a
|
||||
direct connection with the same password.
|
||||
- **403 Host not allowed**: set `PASEO_HOSTNAMES` to the DNS names you use.
|
||||
- **Provider not available**: install that agent CLI in a child image or mount a
|
||||
runtime where the binary is on `PATH`.
|
||||
- **Permission errors in `/workspace`**: make the mounted directory writable by
|
||||
uid/gid `1000:1000`, or run the container as the host uid/gid.
|
||||
- **Logs**: inspect `docker logs paseo` or
|
||||
`/home/paseo/.paseo/daemon.log` inside the container.
|
||||
128
docs/expo-router.md
Normal file
128
docs/expo-router.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Expo Router
|
||||
|
||||
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
|
||||
not fail loudly when a nested native route is mounted under the wrong layout. The
|
||||
usual symptom is a white or blank native screen with no JavaScript crash.
|
||||
|
||||
Read this before changing `packages/app/src/app`, startup routing, remembered
|
||||
workspace restore, or active workspace selection.
|
||||
|
||||
## Ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory.
|
||||
|
||||
- The root layout registers `h/[serverId]`.
|
||||
- The root layout does not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index`.
|
||||
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
||||
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
||||
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
|
||||
|
||||
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
||||
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
||||
shape can leave a nested index route mounted without its local dynamic params and
|
||||
render a blank screen.
|
||||
|
||||
## Startup
|
||||
|
||||
The root `/` route chooses a host boundary. It does not jump directly into a host
|
||||
leaf.
|
||||
|
||||
- Good: `/` -> `/h/[serverId]`
|
||||
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
|
||||
|
||||
`/h/[serverId]` is the host home route. The host index restores the last
|
||||
remembered workspace for that host after the remembered selection has hydrated
|
||||
and the workspace has not been proven missing. If there is no restorable
|
||||
workspace, it goes to global `/open-project`.
|
||||
|
||||
This restore is based on the last navigated workspace, not current connection
|
||||
status. Do not redirect to another online host just because the remembered host
|
||||
is still connecting or offline; the workspace screen owns that offline/loading
|
||||
state.
|
||||
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
||||
Do not make the caller branch on its current route.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
Hidden host routes may keep their local params while an app-wide route is
|
||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||
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.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
Do not paper over missing required params by reading global params in the leaf.
|
||||
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
|
||||
startup route shape.
|
||||
|
||||
Use the host route context for host-owned leaves that need the host id after
|
||||
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
|
||||
unmatched tree by guessing from global state.
|
||||
|
||||
## App Directory
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree.
|
||||
|
||||
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
|
||||
non-route directory.
|
||||
|
||||
## Native Stack
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId` and
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
route-tree state, so a real regression should launch native with seeded persisted
|
||||
state:
|
||||
|
||||
1. Seed `paseo:last-workspace-route-selection` with a valid
|
||||
`{ serverId, workspaceId }`.
|
||||
2. Launch the native app cold.
|
||||
3. Assert a real screen is visible, not the blank tree.
|
||||
4. Assert no `[Layout children]` warning appears.
|
||||
|
||||
The pure policy tests should still enforce the boundary split:
|
||||
|
||||
- root startup with a saved workspace returns `/h/[serverId]`;
|
||||
- host index with the same saved workspace returns
|
||||
`/h/[serverId]/workspace/[workspaceId]`;
|
||||
- host index with no restorable workspace returns `/open-project`.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
before stores, themes, or rendering.
|
||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `which opencode`: `opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
|
||||
@@ -73,7 +73,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)
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, 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
|
||||
|
||||
42
docs/protocol-validation.md
Normal file
42
docs/protocol-validation.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Protocol Validation
|
||||
|
||||
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
||||
|
||||
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
||||
|
||||
## Runtime Path
|
||||
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||
|
||||
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
||||
|
||||
## Codegen Ownership
|
||||
|
||||
The protocol package owns generation.
|
||||
|
||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||
|
||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||
|
||||
## Regression Tests
|
||||
|
||||
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
||||
|
||||
- discriminated-union branch output must propagate `.default()` fields
|
||||
- current sequential item routing must accept `tool_call`-like status branches
|
||||
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
||||
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
||||
|
||||
## Schema Purity
|
||||
|
||||
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
||||
|
||||
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
||||
|
||||
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
||||
@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
@@ -56,15 +58,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ Before running any stable patch release command:
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
The Docker workflow builds images 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`.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
@@ -271,9 +273,30 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
For Docker-only retries, **do not push or force-push a `v*` release tag**.
|
||||
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
|
||||
and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
desktop, APK, or EAS release builders. The Docker exception is safe because the
|
||||
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
|
||||
not check out or move the `v*` release tag.
|
||||
|
||||
To retry a failed non-Docker release workflow, push a retry tag on the commit
|
||||
you want to build. Reusing the same tag name is expected: move it with
|
||||
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||
you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
|
||||
@@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"trustedProxies": ["loopback", "172.16.0.0/12"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
@@ -77,6 +90,7 @@ server {
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
|
||||
|
||||
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-riRET+7Y8oJnMrydvzliE7/pe8T3/75EcIwGXw8vnCM=
|
||||
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||
|
||||
1706
package-lock.json
generated
1706
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -55,9 +55,10 @@
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
@@ -6,8 +6,20 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
waitForScrollableChat,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
@@ -25,6 +37,89 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-away-",
|
||||
title: "Scroll-away anchor",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
|
||||
|
||||
const finalMetrics = await readScrollMetrics(page);
|
||||
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await withWorkspace({
|
||||
prefix: "stream-scroll-away-delayed-history-",
|
||||
});
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await selectModel(page, "Five minute stream");
|
||||
|
||||
const prompt = "Stream for delayed authoritative history scroll-away test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
@@ -36,6 +131,7 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await scrollAgentChatToBottom(page);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
49
packages/app/e2e/command-center-host.spec.ts
Normal file
49
packages/app/e2e/command-center-host.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
@@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from(
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
const TEST_JSON = {
|
||||
name: "config.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
@@ -172,6 +178,47 @@ test.describe("Composer attachments", () => {
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in active chat", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -142,6 +148,50 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const summary = await page
|
||||
.locator('[data-testid^="workspace-deck-entry-"]')
|
||||
.evaluateAll((elements, target) => {
|
||||
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
||||
const entries = elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
testId: element.getAttribute("data-testid"),
|
||||
hasLayout: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
});
|
||||
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
||||
return {
|
||||
totalDeckEntryCount: entries.length,
|
||||
currentWorkspaceEntryCount: currentEntries.length,
|
||||
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
||||
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
||||
};
|
||||
}, input);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalDeckEntryCount: input.expectedDeckEntryCount,
|
||||
currentWorkspaceEntryCount: 1,
|
||||
visibleCurrentWorkspaceEntryCount: 1,
|
||||
hiddenCurrentWorkspaceEntryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
@@ -308,6 +358,82 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
|
||||
try {
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-a-",
|
||||
title: "Autocomplete new route A",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-b-",
|
||||
title: "Autocomplete new route B",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-c-",
|
||||
title: "Autocomplete new route C",
|
||||
}),
|
||||
);
|
||||
|
||||
const [first, second, third] = sessions;
|
||||
|
||||
await openAgentRoute(page, first);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSettingsThenBackToWorkspace(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: third.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: first.workspaceId,
|
||||
});
|
||||
|
||||
await composerLocator(page).fill("/");
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ hasText: "/help", visible: true })
|
||||
.first();
|
||||
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -73,6 +73,23 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill("paseo-loading-state-no-match");
|
||||
|
||||
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||
// project row in place with a ghost "+ New workspace" child row.
|
||||
test.describe("Project with no workspaces persists", () => {
|
||||
|
||||
@@ -10,6 +10,9 @@ import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
host?: string;
|
||||
@@ -403,11 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
);
|
||||
}
|
||||
|
||||
interface DictationConfig {
|
||||
openAiUsable: boolean;
|
||||
localModelsDir: string | null;
|
||||
}
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
@@ -440,7 +438,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
async function logSpeechHarnessConfig(): Promise<void> {
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
@@ -451,23 +449,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
// Default app E2E does not cover speech flows. Keep speech disabled here so
|
||||
// unrelated tests never start background local-model downloads.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
return;
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
|
||||
);
|
||||
return { openAiUsable, localModelsDir };
|
||||
}
|
||||
|
||||
interface RelayStreamState {
|
||||
@@ -572,8 +567,18 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
relayProcess = spawn(
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
process.execPath,
|
||||
[
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
@@ -656,39 +661,28 @@ interface DaemonSpawnArgs {
|
||||
paseoHome: string;
|
||||
fakeEditorBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
}
|
||||
: {}),
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
@@ -720,6 +714,15 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
async function removeTempTree(targetPath: string): Promise<void> {
|
||||
await rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 40,
|
||||
retryDelay: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
@@ -731,13 +734,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome && shouldRemovePaseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await removeTempTree(paseoHome);
|
||||
paseoHome = null;
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeEditorBinDir) {
|
||||
await rm(fakeEditorBinDir, { recursive: true, force: true });
|
||||
await removeTempTree(fakeEditorBinDir);
|
||||
fakeEditorBinDir = null;
|
||||
}
|
||||
}
|
||||
@@ -761,7 +764,7 @@ export default async function globalSetup() {
|
||||
|
||||
const cleanup = () => performCleanup(shouldRemovePaseoHome);
|
||||
|
||||
const dictation = await resolveDictationConfig();
|
||||
await logSpeechHarnessConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
@@ -777,7 +780,6 @@ export default async function globalSetup() {
|
||||
paseoHome,
|
||||
fakeEditorBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
@@ -54,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -66,3 +86,65 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function waitForScrollableChat(
|
||||
page: Page,
|
||||
input: { minScrollableDistance: number; timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight - metrics.viewportHeight;
|
||||
},
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
.toBeGreaterThan(input.minScrollableDistance);
|
||||
}
|
||||
|
||||
export async function scrollChatAwayFromBottom(
|
||||
page: Page,
|
||||
input: { deltaY: number; minDistanceFromBottom: number },
|
||||
): Promise<ScrollMetrics> {
|
||||
const scroll = getVisibleChatScroll(page);
|
||||
const box = await scroll.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(input.minDistanceFromBottom);
|
||||
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
|
||||
): Promise<void> {
|
||||
const durationMs = input?.durationMs ?? 2_000;
|
||||
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
|
||||
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
|
||||
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
await page.waitForTimeout(sampleIntervalMs);
|
||||
const metrics = await readScrollMetrics(page);
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
offsetY: metrics.offsetY,
|
||||
contentHeight: metrics.contentHeight,
|
||||
});
|
||||
expect(
|
||||
metrics.offsetY,
|
||||
JSON.stringify({ baseline, samples: samples.slice(-12) }),
|
||||
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
|
||||
}
|
||||
}
|
||||
|
||||
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreatedAgentTimelineGate {
|
||||
release(): void;
|
||||
waitForCreatedAgent(): Promise<string>;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return message.payload && typeof message.payload === "object"
|
||||
? (message.payload as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
let createdAgentId: string | null = null;
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
let resolveForwardedResponse: (() => void) | null = null;
|
||||
const createdAgentSeen = new Promise<string>((resolve) => {
|
||||
resolveCreatedAgent = resolve;
|
||||
});
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
const forwardedResponse = new Promise<void>((resolve) => {
|
||||
resolveForwardedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
const forwardToClient = (message: WebSocketMessage) => {
|
||||
ws.send(message);
|
||||
resolveForwardedResponse?.();
|
||||
};
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
|
||||
const agentId = payload.agentId;
|
||||
if (typeof agentId === "string") {
|
||||
createdAgentId = agentId;
|
||||
resolveCreatedAgent?.(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMessage?.type === "fetch_agent_timeline_response") {
|
||||
const agentId = payload?.agentId;
|
||||
const direction = payload?.direction;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
typeof agentId === "string" &&
|
||||
agentId === createdAgentId &&
|
||||
direction === "tail"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
forwardToClient(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => forwardToClient(message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreatedAgent: () => createdAgentSeen,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
@@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const modelButton = page
|
||||
.getByRole("button", { name: /Select model/i })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await modelButton.isVisible().catch(() => false)) {
|
||||
await modelButton.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
|
||||
@@ -3,11 +3,12 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildSessionsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export interface ArchiveTabAgent {
|
||||
@@ -87,11 +88,13 @@ export async function archiveAgentFromDaemon(
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
@@ -219,9 +222,7 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
|
||||
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
9
packages/app/e2e/helpers/command-center.ts
Normal file
9
packages/app/e2e/helpers/command-center.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -95,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function dropFileOnComposer(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const dataTransfer = await page.evaluateHandle(
|
||||
({ name, mimeType, base64 }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
const droppedFile = new File([bytes], name, { type: mimeType });
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(droppedFile);
|
||||
return transfer;
|
||||
},
|
||||
{
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.buffer.toString("base64"),
|
||||
},
|
||||
);
|
||||
|
||||
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
|
||||
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("dragover", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("drop", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
|
||||
// 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: {
|
||||
...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",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
79
packages/app/e2e/helpers/hosts.ts
Normal file
79
packages/app/e2e/helpers/hosts.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function openNewWorkspaceComposer(
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export async function openNewWorkspaceComposer(
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
||||
// rpc_error shape the daemon emits for failed async session requests.
|
||||
export async function installReadTransportFailure(
|
||||
page: Page,
|
||||
): Promise<{ allowRecovery: () => void }> {
|
||||
let shouldFailReads = true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "read_project_config_request",
|
||||
error: "Test read transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
allowRecovery() {
|
||||
shouldFailReads = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
|
||||
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
||||
if (input.total > 1) {
|
||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
||||
// either role by accessible name.
|
||||
function questionOption(page: Page, option: string) {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
|
||||
}
|
||||
|
||||
// The multi-question nav renders as a tablist; each question is a tab.
|
||||
function questionNavTab(page: Page, input: { index: number; total: number }) {
|
||||
return page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await questionOption(page, option).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
await questionNavTab(page, input).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionNavigationEnabled(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toBeEnabled();
|
||||
await expect(questionNavTab(page, input)).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
|
||||
17
packages/app/e2e/helpers/route-assertions.ts
Normal file
17
packages/app/e2e/helpers/route-assertions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectAppRoute(
|
||||
page: Page,
|
||||
expectedRoute: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const current = new URL(page.url());
|
||||
return `${current.pathname}${current.search}`;
|
||||
},
|
||||
{ timeout: options?.timeout },
|
||||
)
|
||||
.toBe(expectedRoute);
|
||||
}
|
||||
249
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
249
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
import { type SeededWorkspace } from "./seed-client";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildFakeProviderEntries(nowIso: string) {
|
||||
return [
|
||||
{
|
||||
provider: "mock",
|
||||
label: "Mock",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
fetchedAt: nowIso,
|
||||
models: [
|
||||
{
|
||||
provider: "mock",
|
||||
id: FAKE_HOST_MODEL_ID,
|
||||
label: FAKE_HOST_MODEL_LABEL,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [{ id: "load-test", label: "Load test" }],
|
||||
defaultModeId: "load-test",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
|
||||
const parsed = parseJson(message);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope = parsed as { type?: string; message?: SessionRequest };
|
||||
if (envelope.type !== "session" || !envelope.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.message;
|
||||
}
|
||||
|
||||
function getRequestId(request: SessionRequest): string {
|
||||
return typeof request.requestId === "string" ? request.requestId : "fake-request";
|
||||
}
|
||||
|
||||
export async function buildFakeScheduleHostWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<FakeScheduleHostWorkspace> {
|
||||
const workspaceList = await workspace.client.fetchWorkspaces({
|
||||
filter: { projectId: workspace.projectId },
|
||||
});
|
||||
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
|
||||
if (!baseWorkspace) {
|
||||
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
|
||||
}
|
||||
|
||||
const projectId = `${workspace.projectId}-fake-host`;
|
||||
const cwd = `${workspace.repoPath}-fake-host`;
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
id: `${baseWorkspace.id}-fake-host`,
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
projectRootPath: cwd,
|
||||
workspaceDirectory: cwd,
|
||||
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
project: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installFakeScheduleHost(input: {
|
||||
page: Page;
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
const parsed = parseJson(message);
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
|
||||
ws.send(
|
||||
buildSessionMessage("status", {
|
||||
status: "server_info",
|
||||
serverId: input.serverId,
|
||||
hostname: "fake-schedule-host",
|
||||
version: "0.0.0-e2e",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = readSessionRequest(message);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = getRequestId(request);
|
||||
const now = Date.now();
|
||||
const nowIso = new Date(now).toISOString();
|
||||
switch (request.type) {
|
||||
case "ping":
|
||||
ws.send(
|
||||
buildSessionMessage("pong", {
|
||||
requestId,
|
||||
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
|
||||
serverReceivedAt: now,
|
||||
serverSentAt: now,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_workspaces_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_workspaces_response", {
|
||||
requestId,
|
||||
entries: [input.workspace],
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
requestId,
|
||||
entries: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "get_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("get_providers_snapshot_response", {
|
||||
requestId,
|
||||
entries: buildFakeProviderEntries(nowIso),
|
||||
generatedAt: nowIso,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "refresh_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("refresh_providers_snapshot_response", {
|
||||
requestId,
|
||||
acknowledged: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "schedule/list":
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function addFakeScheduleHostAndReload(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: string;
|
||||
}): Promise<void> {
|
||||
const host = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await input.page.evaluate(
|
||||
({ seededHost, keys }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await input.page.reload();
|
||||
}
|
||||
@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import {
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
@@ -33,12 +39,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await expectAppRoute(page, buildProjectsSettingsRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
await expectAppRoute(page, buildSettingsSectionRoute(section));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
@@ -55,9 +61,7 @@ export async function openSettingsHostSection(
|
||||
section: HostSection,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(`settings-host-section-${section}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
|
||||
);
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section));
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
@@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
|
||||
await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -149,7 +153,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string):
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
@@ -189,7 +193,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
@@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
|
||||
);
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
@@ -391,9 +393,9 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
|
||||
// open) that surfaces the local host by its label. The "Local" marker only
|
||||
// appears on dropdown rows in the multi-host case, which this fixture does not
|
||||
// exercise.
|
||||
// open) that surfaces the local host by its label. The per-row connection
|
||||
// endpoint only appears on dropdown rows in the multi-host case, which this
|
||||
// fixture does not exercise.
|
||||
const picker = sidebar.getByTestId("settings-host-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
|
||||
32
packages/app/e2e/helpers/speech-env.ts
Normal file
32
packages/app/e2e/helpers/speech-env.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
const DISABLED_E2E_SPEECH_ENV = {
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
} as const;
|
||||
|
||||
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
// Default app E2E does not cover speech flows; keep restarts from starting
|
||||
// background local-model downloads for unrelated tests.
|
||||
const next: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
...DISABLED_E2E_SPEECH_ENV,
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -156,14 +156,15 @@ class StartupAssertions {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async expectsSavedHostShell(input: { label: string }): Promise<this> {
|
||||
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
|
||||
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
await this.page.getByTestId("sidebar-hosts-trigger").click();
|
||||
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
@@ -59,9 +59,7 @@ export async function switchWorkspaceViaSidebar(input: {
|
||||
await row.click();
|
||||
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,16 +6,19 @@ import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
@@ -38,6 +41,19 @@ test.describe("New workspace entry points", () => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-new-workspace-host",
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
@@ -48,16 +64,96 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(globalButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(page.getByTestId("host-chooser")).toHaveCount(0);
|
||||
|
||||
// The screen is up: its project picker trigger is the canonical landmark.
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the New Workspace screen hides the host selector when there is only one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
const otherProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "aa-new-workspace-archive-other-",
|
||||
});
|
||||
const rememberedProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "zz-new-workspace-archive-remembered-",
|
||||
});
|
||||
const serverId = getServerId();
|
||||
const draftText = "keep this new workspace draft";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId,
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page
|
||||
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await composer.fill(draftText);
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
|
||||
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
|
||||
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
} finally {
|
||||
await otherProject.cleanup();
|
||||
await rememberedProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -119,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
|
||||
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const OFFLINE_SERVER_IDS = [
|
||||
"srv_e2e_preselect_offline_1",
|
||||
"srv_e2e_preselect_offline_2",
|
||||
"srv_e2e_preselect_offline_3",
|
||||
];
|
||||
|
||||
// New Workspace preselection is a form-context decision, not startup routing.
|
||||
// Entry points from a workspace should carry the current project context, and a
|
||||
// plain /new must not let a stale remembered offline host steal the initial host
|
||||
// when there is exactly one online saved host.
|
||||
|
||||
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+n`);
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
projectDisplayName: string,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
|
||||
}
|
||||
|
||||
async function expectAnyProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const label = ((await projectPicker.textContent()) ?? "").trim();
|
||||
return label || "Choose project";
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.not.toBe("Choose project");
|
||||
}
|
||||
|
||||
async function openColdRestoredWorkspaceWithOfflineHostFirst(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId, workspaceId }) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: connectedServerId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function openNewWorkspaceWithStaleOfflineSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(buildNewWorkspaceRoute());
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function seedOfflineHostsWithStaleSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("New workspace preselects the open workspace's project", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
let projectA: SeededWorkspace;
|
||||
let projectB: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
|
||||
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await projectA?.cleanup();
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNewWorkspaceWithStaleOfflineSelection(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectAnyProjectPreselectedWithin(page, 8_000);
|
||||
});
|
||||
|
||||
test("stale remembered offline host heals after visiting the connected workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const connectedServerId = getServerId();
|
||||
await seedOfflineHostsWithStaleSelection(page);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -131,7 +133,64 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
return readFile(path.join(project.path, "paseo.json"), "utf8");
|
||||
}
|
||||
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: path.basename(projectPath) })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`);
|
||||
await expect(openSettingsItem).toBeVisible({ timeout: 10_000 });
|
||||
await openSettingsItem.click();
|
||||
}
|
||||
|
||||
test.describe("Projects settings", () => {
|
||||
test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||
const client = await connectSeedClient();
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
projectId = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectId);
|
||||
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
@@ -223,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
// Reject read_project_config_request calls until the user clicks Reload.
|
||||
// This keeps automatic reconnect refetches from racing past the callout.
|
||||
const transportFailure = await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
@@ -233,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
||||
transportFailure.allowRecovery();
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
|
||||
92
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
92
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
|
||||
return result.schedule.id;
|
||||
}
|
||||
|
||||
function ignoreScheduleDeleteError(): void {}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
await (workspace.client as unknown as ScheduleSeedClient)
|
||||
.scheduleDelete({ id })
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
153
packages/app/e2e/schedules-project-target.spec.ts
Normal file
153
packages/app/e2e/schedules-project-target.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
target: { type: string; config?: { cwd?: string } };
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: /select model/i }).click();
|
||||
const popup = page.getByTestId("combobox-desktop-container");
|
||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||
await popup.getByText(label, { exact: true }).click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (schedule) {
|
||||
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScheduleCreatedForProject(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||
expect(schedule).toEqual(
|
||||
expect.objectContaining({
|
||||
name: input.name,
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Schedules project target", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByRole("button", { name: "Schedules" }).click();
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
await expect(page).not.toHaveURL(/\/h\//);
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
||||
await page.getByRole("button", { name: "Cron" }).click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
||||
});
|
||||
|
||||
test("clears the selected model when the chosen project moves to another host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
fakeHost.projectDisplayName,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
test("sidebar pins the local daemon host first", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildOpenProjectRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
removeCurrentHostFromSettings,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -121,7 +127,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
@@ -133,10 +139,10 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
@@ -144,10 +150,10 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("about"));
|
||||
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
@@ -158,7 +164,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
@@ -167,11 +173,11 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
|
||||
@@ -188,11 +194,11 @@ test.describe("Settings — compact master-detail", () => {
|
||||
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
@@ -206,7 +212,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await openSettingsHostSection(page, getServerId(), "host");
|
||||
await removeCurrentHostFromSettings(page);
|
||||
await closeCompactSettings(page);
|
||||
|
||||
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
||||
test.describe("Model B sidebar shape", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||
// none, since worktree creation needs a git checkout.
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The global new-workspace button is the universal entry — present for both
|
||||
// kinds regardless of their per-row affordance.
|
||||
|
||||
@@ -132,6 +132,25 @@ test.describe("Sidebar workspace list", () => {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card shows host as metadata", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await row.hover();
|
||||
|
||||
const hoverCard = page.getByTestId("workspace-hover-card");
|
||||
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ test.describe("Startup loading presentation", () => {
|
||||
})
|
||||
.openRoot();
|
||||
|
||||
await startup.expectsSavedHostShell({ label: "Dev" });
|
||||
await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" });
|
||||
await startup.expectsNoSavedHostErrorStatus();
|
||||
await startup.expectsNoLocalServerStartupCopy();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: input.paseoHome,
|
||||
PASEO_SERVER_ID: SERVER_ID,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: input.origin,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
@@ -105,12 +106,8 @@ async function expectWorkspaceLocation(
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId);
|
||||
await expectAppRoute(page, workspaceRoute, { timeout: 30_000 });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: input.workspace.workspaceName,
|
||||
subtitle: input.workspace.projectDisplayName,
|
||||
@@ -259,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
|
||||
|
||||
await expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -133,7 +133,7 @@
|
||||
"serve-sim": "^0.1.40",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.75.0",
|
||||
"wrangler": "^4.105.0",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
|
||||
return owners;
|
||||
}
|
||||
|
||||
function footerAssistantIds(layout: StreamLayout): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...layout.liveHead.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...layout.history, ...layout.liveHead].flatMap((item) =>
|
||||
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
@@ -289,4 +309,144 @@ describe("layoutStream", () => {
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([tool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places split live-head tool footer using the assistant from history on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
head: [tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[tool.id]: assistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for footer content while placing after the visible turn end on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
const latestAssistant = assistantMessage("a2", 4);
|
||||
const latestTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
firstTool,
|
||||
latestAssistant,
|
||||
latestTool,
|
||||
userMessage("u2", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, latestAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestTool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const secondAssistant = assistantMessage("a2", 4);
|
||||
const secondTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
userMessage("u2", 3),
|
||||
secondAssistant,
|
||||
secondTool,
|
||||
userMessage("u3", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, secondAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
|
||||
firstAssistant.id,
|
||||
);
|
||||
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
|
||||
secondAssistant.id,
|
||||
);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[firstAssistant.id]: firstAssistant.id,
|
||||
[secondTool.id]: secondAssistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"does not render a completed footer before tool rows while the turn is running on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
@@ -68,45 +75,111 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
;
|
||||
index >= 0 && index < items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!canCrossBoundary ||
|
||||
!input.boundaryAboveItems ||
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
index = input.boundaryAboveIndex;
|
||||
canCrossBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (latestIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
function resolveCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running"))
|
||||
);
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
||||
function isToolSequenceItem(
|
||||
item: StreamItem | null,
|
||||
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
@@ -174,19 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -227,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
@@ -245,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
} else {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -253,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -262,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -270,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -194,7 +194,9 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
expect(scrollContainer).toBeInstanceOf(HTMLElement);
|
||||
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: 64 });
|
||||
@@ -205,4 +207,408 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
const 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,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
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: [],
|
||||
},
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
const scrollElement = scrollContainer;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 1400 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollElement.scrollTop).toBe(1400);
|
||||
});
|
||||
|
||||
it("does not force bottom on delayed route readiness after the user scrolls away", async () => {
|
||||
const 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,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
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,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: 1400 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
const 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,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
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,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
|
||||
const 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,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
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,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3)],
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
contentRef.current = node;
|
||||
}, []);
|
||||
const [followOutput, setFollowOutputr] = useState(true);
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
followOutputRef.current = value;
|
||||
setFollowOutputr(value);
|
||||
return value;
|
||||
};
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const isPointerScrollActiveRef = useRef(false);
|
||||
@@ -145,8 +146,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
followOutputRef.current = followOutput;
|
||||
|
||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
|
||||
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
@@ -276,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom) {
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (scrolledUp) {
|
||||
if (scrolledUp || !isAtBottom) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
@@ -318,6 +322,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!isActivationReady) {
|
||||
return;
|
||||
}
|
||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
setFollowOutput(true);
|
||||
forceStickToBottom();
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -336,7 +343,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
|
||||
}, [
|
||||
activationKey,
|
||||
forceStickToBottom,
|
||||
hasRouteBottomAnchorRequest,
|
||||
isActivationReady,
|
||||
scheduleStickToBottom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
|
||||
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
messageId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAssistantTurnBoundaryMessageId", () => {
|
||||
it("uses the selected assistant message id", () => {
|
||||
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), selected],
|
||||
startIndex: 1,
|
||||
}),
|
||||
).toBe("msg-assistant-1");
|
||||
});
|
||||
|
||||
it("does not borrow a boundary id from another assistant in the same turn", () => {
|
||||
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
const selected = assistantMessage("assistant-2", 3);
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), first, selected],
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the selected item to be an assistant message", () => {
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
|
||||
startIndex: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
}): string | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import {
|
||||
AssistantTurnFooter,
|
||||
LiveElapsed,
|
||||
STREAM_METADATA_FONT_SIZE,
|
||||
type AssistantForkTarget,
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||
items,
|
||||
startIndex,
|
||||
});
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
TurnFooter,
|
||||
type AssistantTurnForkHandler,
|
||||
type TurnContentStrategy,
|
||||
} from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
@@ -76,11 +82,21 @@ import {
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
missingAttachmentMessage: string;
|
||||
}): WorkspaceComposerAttachment {
|
||||
if (!input.payload.attachment) {
|
||||
throw new Error(input.missingAttachmentMessage);
|
||||
}
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: `chat_history:${input.draftId}`,
|
||||
attachment: input.payload.attachment,
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||
if (!agent.provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftTabTarget(
|
||||
setup: WorkspaceDraftTabSetup | undefined,
|
||||
draftId: string,
|
||||
): WorkspaceTabTarget {
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
payload,
|
||||
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||
});
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||
attachments: [attachment],
|
||||
});
|
||||
return draftId;
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
const draftId = await prepareForkDraft();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
setup: draftSetup,
|
||||
sourceDirectory,
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
|
||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
function collectAgentProjectPlacementDiffs(
|
||||
left: AgentScreenAgent["projectPlacement"],
|
||||
right: AgentScreenAgent["projectPlacement"],
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
if (left?.projectName !== right?.projectName) {
|
||||
reasons.push("agent.projectPlacement.projectName");
|
||||
}
|
||||
if (left?.projectKey !== right?.projectKey) {
|
||||
reasons.push("agent.projectPlacement.projectKey");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.provider !== right.provider) reasons.push("agent.provider");
|
||||
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
|
||||
if (left.model !== right.model) reasons.push("agent.model");
|
||||
if (left.thinkingOptionId !== right.thinkingOptionId) {
|
||||
reasons.push("agent.thinkingOptionId");
|
||||
}
|
||||
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
|
||||
reasons.push("agent.runtimeInfo.modeId");
|
||||
}
|
||||
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
|
||||
reasons.push("agent.runtimeInfo.model");
|
||||
}
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||
if (left.id !== right.id) reasons.push("agent.id");
|
||||
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
|
||||
if (left.status !== right.status) reasons.push("agent.status");
|
||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||
reasons.push("agent.capabilities");
|
||||
}
|
||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
reasons.push(...collectAgentSetupDiffs(left, right));
|
||||
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
|
||||
return reasons;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -34,6 +40,7 @@ import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
@@ -57,6 +64,7 @@ import {
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -79,6 +87,8 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
@@ -88,11 +98,10 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildOpenProjectRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
@@ -125,14 +134,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,6 +313,9 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
if (hasConfiguredLocalDaemonOverride()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
@@ -427,12 +438,8 @@ function AppContainer({
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const pathname = usePathname();
|
||||
const activeServerId = useMemo(
|
||||
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||
[daemons, pathname],
|
||||
);
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
const { desktop } = usePanelStore.getState();
|
||||
@@ -494,12 +501,10 @@ function AppContainer({
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenter />
|
||||
<HostChooserModal />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
enabled={keyboardShortcutsEnabled}
|
||||
serverId={activeServerId}
|
||||
/>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
@@ -731,7 +736,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -761,48 +766,87 @@ interface OpenProjectEventPayload {
|
||||
path?: unknown;
|
||||
}
|
||||
|
||||
function OpenProjectListener() {
|
||||
const hosts = useHosts();
|
||||
const serverId = hosts[0]?.serverId ?? null;
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const openProject = useOpenProject(serverId);
|
||||
const pendingPathRef = useRef<string | null>(null);
|
||||
interface PendingOpenProjectRequest {
|
||||
id: number;
|
||||
serverId: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const maybeOpenProject = (inputPath: string) => {
|
||||
const nextPath = inputPath.trim();
|
||||
let nextOpenProjectRequestId = 1;
|
||||
|
||||
function OpenProjectListener() {
|
||||
const chooseHost = useHostChooser();
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const [request, setRequest] = useState<PendingOpenProjectRequest | null>(null);
|
||||
const [pendingPath, setPendingPath] = useState<string | null>(null);
|
||||
const openProject = useOpenProject(request?.serverId ?? null);
|
||||
|
||||
const openPathOnChosenHost = useCallback(
|
||||
(path: string) => {
|
||||
const nextPath = path.trim();
|
||||
if (!nextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPathRef.current = nextPath;
|
||||
|
||||
if (!serverId || !client) {
|
||||
if (!hostRegistryLoaded) {
|
||||
setPendingPath(nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathToOpen = pendingPathRef.current;
|
||||
pendingPathRef.current = null;
|
||||
if (!pathToOpen) {
|
||||
return;
|
||||
chooseHost({
|
||||
title: "Choose host",
|
||||
onChooseHost: (serverId) => {
|
||||
setRequest({
|
||||
id: nextOpenProjectRequestId++,
|
||||
serverId,
|
||||
path: nextPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[chooseHost, hostRegistryLoaded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRegistryLoaded || !pendingPath) {
|
||||
return;
|
||||
}
|
||||
const nextPath = pendingPath;
|
||||
setPendingPath(null);
|
||||
openPathOnChosenHost(nextPath);
|
||||
}, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void openProject(request.path).then((result) => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
void openProject(pathToOpen).catch(() => undefined);
|
||||
if (!result.ok) {
|
||||
setRequest((current) => (current?.id === request.id ? null : current));
|
||||
return null;
|
||||
}
|
||||
|
||||
setRequest((current) => (current?.id === request.id ? null : current));
|
||||
return null;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openProject, request]);
|
||||
|
||||
// Pull any path that was passed on cold start (before the listener existed).
|
||||
// Store in the ref even if this effect instance is disposed — the next
|
||||
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
void getDesktopHost()
|
||||
?.getPendingOpenProject?.()
|
||||
?.then((pending) => {
|
||||
if (pending) {
|
||||
pendingPathRef.current = pending;
|
||||
}
|
||||
if (!disposed && pending) {
|
||||
maybeOpenProject(pending);
|
||||
openPathOnChosenHost(pending);
|
||||
}
|
||||
return;
|
||||
})
|
||||
@@ -814,7 +858,7 @@ function OpenProjectListener() {
|
||||
return;
|
||||
}
|
||||
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
||||
maybeOpenProject(nextPath);
|
||||
openPathOnChosenHost(nextPath);
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
@@ -826,13 +870,11 @@ function OpenProjectListener() {
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
maybeOpenProject(pendingPathRef.current ?? "");
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [client, openProject, serverId]);
|
||||
}, [openPathOnChosenHost]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -842,9 +884,16 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const routeHasKnownHost =
|
||||
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
|
||||
const shouldShowAppChrome =
|
||||
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
||||
storeReady &&
|
||||
(pathname === "/open-project" ||
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -899,6 +948,10 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
@@ -908,12 +961,23 @@ function RootStack() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRouteNavigationBridge() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
return registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
|
||||
@@ -39,7 +39,6 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -84,7 +82,7 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent(agentId)
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Redirect } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostOpenProjectRoute() {
|
||||
// COMPAT(hostOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostOpenProjectRouteContent />
|
||||
<Redirect href={buildOpenProjectRoute()} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostOpenProjectRouteContent() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <OpenProjectScreen serverId={serverId} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
import { Redirect } from "expo-router";
|
||||
import { buildSessionsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostAgentsRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostAgentsRouteContent() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <SessionsScreen serverId={serverId} />;
|
||||
export default function HostSessionsRoute() {
|
||||
// COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11.
|
||||
return <Redirect href={buildSessionsRoute()} />;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -63,7 +67,7 @@ function stripOpenSearchParamFromBrowserUrl() {
|
||||
return;
|
||||
}
|
||||
url.searchParams.delete("open");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute(`${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
function clearConsumedOpenIntent(input: {
|
||||
@@ -102,6 +106,13 @@ function HostWorkspaceRouteContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||
}, [serverId, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
|
||||
@@ -2,25 +2,36 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
export default function NewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
draftId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
|
||||
const screenKey = JSON.stringify([
|
||||
serverId,
|
||||
sourceDirectory ?? null,
|
||||
displayName ?? null,
|
||||
projectId ?? null,
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
10
packages/app/src/app/open-project.tsx
Normal file
10
packages/app/src/app/open-project.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
|
||||
export default function OpenProjectRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<OpenProjectScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
10
packages/app/src/app/schedules.tsx
Normal file
10
packages/app/src/app/schedules.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||
|
||||
export default function SchedulesRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SchedulesScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
10
packages/app/src/app/sessions.tsx
Normal file
10
packages/app/src/app/sessions.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
|
||||
export default function SessionsRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SessionsScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import SettingsScreen from "@/screens/settings-screen";
|
||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string }>();
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
const rawSection = typeof params.section === "string" ? params.section : "";
|
||||
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
141
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
141
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
MousePointer2,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
return count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count });
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
|
||||
export function getAgentAttachmentPillContent(
|
||||
attachment: AgentAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
switch (attachment.type) {
|
||||
case "review":
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceAttachmentPillContent(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title,
|
||||
subtitle: getPullRequestContextSubtitle(attachment),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.commentCount, t),
|
||||
};
|
||||
}
|
||||
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
|
||||
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentBrowserIcon = (
|
||||
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
describe("attachment file types", () => {
|
||||
it("keeps SVG as a file while treating raster image files as images", () => {
|
||||
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml");
|
||||
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("application/octet-stream");
|
||||
expect(isRasterImagePath("/tmp/logo.svg")).toBe(false);
|
||||
expect(isRasterImageMimeType("image/svg+xml")).toBe(false);
|
||||
expect(isRasterImageFile(new File(["<svg />"], "logo.svg", { type: "image/svg+xml" }))).toBe(
|
||||
@@ -18,11 +18,20 @@ describe("attachment file types", () => {
|
||||
);
|
||||
|
||||
expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
|
||||
expect(getMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
|
||||
expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true);
|
||||
expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true);
|
||||
expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require MIME table entries for generic file attachments", () => {
|
||||
expect(getMimeTypeFromPath("/tmp/notes.md")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/archive.zip")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/report.docx")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/runtime.log")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/export.anything")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("does not offer SVG in the image picker extension list", () => {
|
||||
expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual(
|
||||
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
|
||||
|
||||
@@ -1,45 +1,3 @@
|
||||
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
".js": "text/javascript",
|
||||
".ts": "text/typescript",
|
||||
".tsx": "text/typescript-jsx",
|
||||
".jsx": "text/javascript",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".xml": "text/xml",
|
||||
".csv": "text/csv",
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
@@ -55,6 +13,7 @@ const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION));
|
||||
const GENERIC_FILE_MIME_TYPE = "application/octet-stream";
|
||||
|
||||
export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map(
|
||||
(extension) => extension.slice(1),
|
||||
@@ -75,7 +34,7 @@ export function getFileTypeLabel(path: string): string | null {
|
||||
}
|
||||
|
||||
export function getMimeTypeFromPath(path: string): string {
|
||||
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
|
||||
return getRasterImageMimeTypeFromPath(path) ?? GENERIC_FILE_MIME_TYPE;
|
||||
}
|
||||
|
||||
export function getRasterImageMimeTypeFromPath(path: string): string | null {
|
||||
|
||||
28
packages/app/src/attachments/picked-file.ts
Normal file
28
packages/app/src/attachments/picked-file.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getFileExtension } from "@/attachments/file-types";
|
||||
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
|
||||
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
|
||||
|
||||
export interface PickedFile {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
|
||||
const { path: managedPath } = await copyDesktopAttachmentFile({
|
||||
attachmentId: crypto.randomUUID(),
|
||||
sourcePath: path,
|
||||
extension: getFileExtension(path) || null,
|
||||
});
|
||||
const base64 = await readDesktopFileBase64(managedPath);
|
||||
return base64ToUint8Array(base64);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user