mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
201 Commits
lenient-co
...
ci/build-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8eec44c1 | ||
|
|
0b820d729a | ||
|
|
0d3b717cf3 | ||
|
|
5da6548aff | ||
|
|
d42ab91971 | ||
|
|
e528a0db06 | ||
|
|
6aba0370ae | ||
|
|
f4509fe044 | ||
|
|
3e8dce7d7c | ||
|
|
47532952f3 | ||
|
|
d7ca1b5a03 | ||
|
|
42e101c81e | ||
|
|
75d784534f | ||
|
|
64c819efeb | ||
|
|
8554b94cdb | ||
|
|
75ea0d4534 | ||
|
|
d791a0aa91 | ||
|
|
943d03ad99 | ||
|
|
38cfe109c9 | ||
|
|
5ef1b9dbb1 | ||
|
|
dfe3330ef8 | ||
|
|
90e0a0e353 | ||
|
|
37bde90d9f | ||
|
|
6804882761 | ||
|
|
13e92f8a30 | ||
|
|
328361667f | ||
|
|
9423b091b9 | ||
|
|
04e893417e | ||
|
|
f06792ae89 | ||
|
|
7f011d16c5 | ||
|
|
319d9017c2 | ||
|
|
4a5630bfce | ||
|
|
5ddd5f3726 | ||
|
|
3673846433 | ||
|
|
abfe955867 | ||
|
|
38e4d9ad5d | ||
|
|
77f6069ec1 | ||
|
|
9c40cb7637 | ||
|
|
d706c4339b | ||
|
|
50ed0d0ab1 | ||
|
|
1b8af2e58e | ||
|
|
d781b68711 | ||
|
|
cd6c608c9e | ||
|
|
1f5283f5a3 | ||
|
|
c0b801b805 | ||
|
|
9ea58aae0c | ||
|
|
f35b16b316 | ||
|
|
81113d1582 | ||
|
|
3b078240a8 | ||
|
|
279e1aa91c | ||
|
|
b4ab0d9db6 | ||
|
|
5e2a8b6633 | ||
|
|
f2ebac931c | ||
|
|
f350c716b9 | ||
|
|
fe9a486ac1 | ||
|
|
13d6ad598e | ||
|
|
218097b7cc | ||
|
|
bac302626f | ||
|
|
d8a8ac252f | ||
|
|
b6d49da4df | ||
|
|
1f7efbd185 | ||
|
|
a1e81685a1 | ||
|
|
8cd83b3a91 | ||
|
|
9553f4b328 | ||
|
|
fc7c753382 | ||
|
|
96e68fea76 | ||
|
|
1a4d7852a8 | ||
|
|
c2a1ac7c3b | ||
|
|
4a1534cacd | ||
|
|
a849bc6bda | ||
|
|
a788a0f843 | ||
|
|
a1581e66b0 | ||
|
|
2658132384 | ||
|
|
ec93ca866e | ||
|
|
66445adc07 | ||
|
|
41d882859c | ||
|
|
88397655f7 | ||
|
|
18de06c2a4 | ||
|
|
a9ba0392b7 | ||
|
|
cf6c014b6b | ||
|
|
e18cfb7639 | ||
|
|
c05e337cde | ||
|
|
71c66823aa | ||
|
|
a1821d5863 | ||
|
|
5db070a4d9 | ||
|
|
461be47c91 | ||
|
|
92d7066601 | ||
|
|
d28e174b38 | ||
|
|
a257a1c5f6 | ||
|
|
144f951a79 | ||
|
|
51fea4b7e0 | ||
|
|
e98ba1beb1 | ||
|
|
3bbbfbc880 | ||
|
|
f86226a196 | ||
|
|
120842264e | ||
|
|
a017ddfc29 | ||
|
|
03dcda7c41 | ||
|
|
ada0955334 | ||
|
|
ca5c786ccf | ||
|
|
5ae53c7e55 | ||
|
|
c800c6a3f1 | ||
|
|
adc9b3b43c | ||
|
|
5d18edd31e | ||
|
|
6c764f211a | ||
|
|
28a27b02d3 | ||
|
|
860fcb2e35 | ||
|
|
4c72bf0209 | ||
|
|
8c639fd796 | ||
|
|
aa656772ef | ||
|
|
4dd783c36a | ||
|
|
e6a316456d | ||
|
|
3c2339f841 | ||
|
|
61a9117a6b | ||
|
|
1f0285c06b | ||
|
|
a689ccb0af | ||
|
|
cac71ee8f5 | ||
|
|
a743a22496 | ||
|
|
b2c6476817 | ||
|
|
6af1379357 | ||
|
|
15d3091104 | ||
|
|
ebaecace2b | ||
|
|
d96d599466 | ||
|
|
29a3261dd6 | ||
|
|
c7406a415d | ||
|
|
202c417347 | ||
|
|
3259a69466 | ||
|
|
cab0e4b3f8 | ||
|
|
6ca1f4dac5 | ||
|
|
6f3ab98986 | ||
|
|
251d88a37a | ||
|
|
c72bcb49e5 | ||
|
|
1a03171f0c | ||
|
|
f3fdeab131 | ||
|
|
fdc6a4a57d | ||
|
|
ed67e6e0f1 | ||
|
|
5ec7ef9771 | ||
|
|
4fb70668f2 | ||
|
|
06c3161008 | ||
|
|
a85139c3f3 | ||
|
|
be756c9004 | ||
|
|
8d0b3e69e8 | ||
|
|
d9b65e269f | ||
|
|
d3e1fe48f6 | ||
|
|
783f43c580 | ||
|
|
503a5a9e1e | ||
|
|
f21c997b17 | ||
|
|
7c1da0077b | ||
|
|
d318629121 | ||
|
|
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 |
@@ -1,11 +1,31 @@
|
||||
.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
|
||||
|
||||
291
.github/workflows/android-apk-release.yml
vendored
291
.github/workflows/android-apk-release.yml
vendored
@@ -7,34 +7,229 @@ on:
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or commit to build. Leave blank to build the selected workflow ref."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
publish:
|
||||
description: "Upload the APK to an existing or new GitHub release."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
description: "Release tag to rebuild and publish. Required when publish is enabled."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
build-apk:
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
artifact-name: ${{ steps.mode.outputs.artifact-name }}
|
||||
publish: ${{ steps.mode.outputs.publish }}
|
||||
release-tag: ${{ steps.mode.outputs.release-tag }}
|
||||
source-tag: ${{ steps.mode.outputs.source-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
- name: Resolve build mode
|
||||
id: mode
|
||||
env:
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
publish=false
|
||||
release_tag=""
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
publish=true
|
||||
source_tag="${GITHUB_REF_NAME}"
|
||||
elif [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_TAG:-}" ]]; then
|
||||
echo "::error::tag is required when publish is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${INPUT_REF:-}" ]]; then
|
||||
echo "::error::ref cannot be combined with publish; the release tag is always the source."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
source_tag="${INPUT_TAG}"
|
||||
fi
|
||||
|
||||
if [[ "${publish}" == "true" ]]; then
|
||||
release_env="$(node scripts/emit-release-env.mjs --source-tag "${source_tag}")"
|
||||
printf '%s\n' "${release_env}" >> "$GITHUB_ENV"
|
||||
release_tag="$(printf '%s\n' "${release_env}" | sed -n 's/^RELEASE_TAG=//p')"
|
||||
artifact_name="paseo-${release_tag}-android.apk"
|
||||
else
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||
artifact_name="paseo-v${package_version}-${short_sha}-android.apk"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "artifact-name=${artifact_name}"
|
||||
echo "publish=${publish}"
|
||||
echo "release-tag=${release_tag}"
|
||||
echo "source-tag=${source_tag:-}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Building ${artifact_name}; publish=${publish}"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Restore production Android credentials
|
||||
env:
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
required=(
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64
|
||||
ANDROID_KEY_ALIAS
|
||||
ANDROID_KEY_PASSWORD
|
||||
ANDROID_KEYSTORE_BASE64
|
||||
ANDROID_KEYSTORE_PASSWORD
|
||||
)
|
||||
for name in "${required[@]}"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "::error::Missing required repository secret: ${name}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
google_services_file="$RUNNER_TEMP/google-services.prod.json"
|
||||
keystore_file="$RUNNER_TEMP/paseo-upload.jks"
|
||||
|
||||
printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > "$google_services_file"
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_file"
|
||||
jq -n \
|
||||
--arg keystorePath "$keystore_file" \
|
||||
--arg keystorePassword "$ANDROID_KEYSTORE_PASSWORD" \
|
||||
--arg keyAlias "$ANDROID_KEY_ALIAS" \
|
||||
--arg keyPassword "$ANDROID_KEY_PASSWORD" \
|
||||
'{android: {keystore: {keystorePath: $keystorePath, keystorePassword: $keystorePassword, keyAlias: $keyAlias, keyPassword: $keyPassword}}}' \
|
||||
> packages/app/credentials.json
|
||||
|
||||
echo "GOOGLE_SERVICES_FILE_PROD=$google_services_file" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build signed production APK on GitHub
|
||||
env:
|
||||
APP_VARIANT: production
|
||||
EAS_BUILD: "1"
|
||||
EXPO_UPDATES_CHANNEL: production
|
||||
GRADLE_OPTS: >-
|
||||
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
-Dorg.gradle.parallel=false
|
||||
-Dorg.gradle.workers.max=1
|
||||
-Dorg.gradle.daemon=false
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
npm run build:app-deps
|
||||
npm --prefix packages/app run build:terminal-webview
|
||||
|
||||
cd packages/app
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
node -e '(async () => require("expo/node_modules/@expo/config-plugins").AndroidConfig.EasBuild.configureEasBuildAsync(process.cwd()))().catch((error) => { console.error(error); process.exitCode = 1; })'
|
||||
|
||||
cd android
|
||||
./gradlew \
|
||||
:app:assembleRelease \
|
||||
--no-daemon \
|
||||
--max-workers=1 \
|
||||
-Dorg.gradle.parallel=false \
|
||||
-x lint \
|
||||
-x lintVitalAnalyzeRelease \
|
||||
-x lintVitalRelease \
|
||||
-x generateReleaseLintModel \
|
||||
-x generateReleaseLintVitalModel
|
||||
|
||||
artifact="$RUNNER_TEMP/${{ steps.mode.outputs.artifact-name }}"
|
||||
cp app/build/outputs/apk/release/app-release.apk "$artifact"
|
||||
|
||||
apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)"
|
||||
if [[ -z "$apksigner" ]]; then
|
||||
echo "::error::Unable to locate apksigner."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_sha256="421698bdca5bb9168c970e24539781509b5aa23fab8ab406a15b3f9c5f04c647"
|
||||
actual_sha256="$($apksigner verify --print-certs "$artifact" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
|
||||
echo "::error::APK signing certificate does not match the production key."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified production signing certificate: $actual_sha256"
|
||||
|
||||
- name: Upload APK to workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.mode.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ steps.mode.outputs.artifact-name }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish-apk:
|
||||
needs: build-apk
|
||||
if: needs.build-apk.outputs.publish == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ needs.build-apk.outputs.source-tag }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "${{ needs.build-apk.outputs.source-tag }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
@@ -62,71 +257,19 @@ jobs:
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download APK workflow artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Expo and EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Android APK on EAS
|
||||
id: eas_build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
|
||||
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
|
||||
|
||||
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
|
||||
if [ -z "$build_id" ]; then
|
||||
echo "Failed to determine EAS build ID."
|
||||
cat "$RUNNER_TEMP/eas-build.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve APK artifact URL
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
|
||||
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
|
||||
|
||||
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
|
||||
if [ -z "$artifact_url" ]; then
|
||||
echo "Failed to determine APK artifact URL."
|
||||
cat "$RUNNER_TEMP/eas-build-view.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
asset_name="paseo-${RELEASE_TAG}-android.apk"
|
||||
asset_path="$RUNNER_TEMP/$asset_name"
|
||||
|
||||
curl --fail --location --output "$asset_path" "$artifact_url"
|
||||
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
name: ${{ needs.build-apk.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/apk
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
gh release upload \
|
||||
"$RELEASE_TAG" \
|
||||
"$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \
|
||||
--clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
|
||||
132
.github/workflows/ci.yml
vendored
132
.github/workflows/ci.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Lint lockfile
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
@@ -75,8 +75,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -112,10 +111,9 @@ jobs:
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
@@ -133,50 +131,56 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
npm ci
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
if ($attempt -eq 3) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -190,19 +194,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -226,8 +218,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -244,8 +235,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
include:
|
||||
- { label: "shard 1/4", shard: 1, desktop: false }
|
||||
- { label: "shard 2/4", shard: 2, desktop: false }
|
||||
- { label: "shard 3/4", shard: 3, desktop: false }
|
||||
- { label: "shard 4/4", shard: 4, desktop: false }
|
||||
- { label: "desktop overlay", shard: "desktop", desktop: true }
|
||||
name: playwright (${{ matrix.label }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -258,19 +254,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -282,13 +266,19 @@ jobs:
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
@@ -312,7 +302,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build:relay
|
||||
@@ -338,10 +328,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
|
||||
3
.github/workflows/deploy-app.yml
vendored
3
.github/workflows/deploy-app.yml
vendored
@@ -24,10 +24,9 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
9
.github/workflows/deploy-relay.yml
vendored
9
.github/workflows/deploy-relay.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -21,7 +18,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
2
.github/workflows/deploy-website.yml
vendored
2
.github/workflows/deploy-website.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
40
.github/workflows/desktop-release.yml
vendored
40
.github/workflows/desktop-release.yml
vendored
@@ -127,7 +127,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -158,6 +158,7 @@ jobs:
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
@@ -165,6 +166,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-macos-${{ matrix.electron_arch }}
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -227,7 +237,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -248,7 +258,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
@@ -256,6 +266,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --linux --x64)
|
||||
@@ -263,6 +274,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -325,7 +345,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -351,6 +371,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --win --x64 --arm64)
|
||||
@@ -358,6 +379,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-windows-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -425,7 +455,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
2
.github/workflows/desktop-rollout.yml
vendored
2
.github/workflows/desktop-rollout.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
62
.github/workflows/docker.yml
vendored
62
.github/workflows/docker.yml
vendored
@@ -5,7 +5,22 @@ on:
|
||||
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:
|
||||
@@ -13,7 +28,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Paseo version to build. Required when publish is true."
|
||||
description: "Expected source version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
@@ -24,15 +39,6 @@ on:
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
@@ -57,7 +63,6 @@ jobs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
@@ -68,7 +73,6 @@ jobs:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -76,9 +80,9 @@ jobs:
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
@@ -87,7 +91,6 @@ jobs:
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -104,24 +107,6 @@ jobs:
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -137,14 +122,13 @@ jobs:
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
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} source_build=${source_build}"
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
@@ -160,8 +144,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
@@ -193,8 +177,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
|
||||
7
.github/workflows/nix.yml
vendored
7
.github/workflows/nix.yml
vendored
@@ -9,12 +9,15 @@ on:
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/build-daemon-web-ui.mjs"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
@@ -64,7 +67,7 @@ jobs:
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
@@ -72,6 +75,8 @@ jobs:
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
||||
[[ -s "$PASEO_HOME/web-ui.html" ]]
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
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/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
|
||||
_.path = [
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
|
||||
]
|
||||
|
||||
[tools]
|
||||
java = "17"
|
||||
java = "21"
|
||||
|
||||
192
.oxlintrc.json
192
.oxlintrc.json
@@ -84,6 +84,198 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@tanstack/react-query",
|
||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||
},
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/data/**/*.{ts,tsx}",
|
||||
"packages/app/src/**/*.test.{ts,tsx}",
|
||||
"packages/app/src/**/*.spec.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/assistant-file-links/use-file-link.ts",
|
||||
"packages/app/src/components/message.tsx",
|
||||
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
||||
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
||||
"packages/app/src/desktop/hooks/use-install-status.ts",
|
||||
"packages/app/src/desktop/settings/desktop-settings.ts",
|
||||
"packages/app/src/git/pull-request-panel/use-data.ts",
|
||||
"packages/app/src/git/use-github-search-query.ts",
|
||||
"packages/app/src/git/use-pr-status-query.ts",
|
||||
"packages/app/src/git/use-status-query.ts",
|
||||
"packages/app/src/hooks/use-archive-agent.ts",
|
||||
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
||||
"packages/app/src/hooks/use-agent-commands-query.ts",
|
||||
"packages/app/src/hooks/use-agent-history.ts",
|
||||
"packages/app/src/hooks/use-branch-switcher.ts",
|
||||
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
||||
"packages/app/src/hooks/use-draft-agent-features.ts",
|
||||
"packages/app/src/hooks/use-form-preferences.ts",
|
||||
"packages/app/src/hooks/use-is-local-daemon.ts",
|
||||
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
||||
"packages/app/src/hooks/use-preferred-editor.ts",
|
||||
"packages/app/src/hooks/use-project-icon-query.ts",
|
||||
"packages/app/src/hooks/use-settings/index.ts",
|
||||
"packages/app/src/panels/terminal-panel.tsx",
|
||||
"packages/app/src/projects/project-icons.ts",
|
||||
"packages/app/src/provider-usage/use-provider-usage.ts",
|
||||
"packages/app/src/screens/project-settings-screen.tsx",
|
||||
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
||||
"packages/app/src/workspace/desktop-open-targets.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/app/_layout.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/agent-status-dot.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/browser-pane.electron.tsx",
|
||||
"packages/app/src/components/browser-pane.tsx",
|
||||
"packages/app/src/components/browser-pane.web.tsx",
|
||||
"packages/app/src/components/command-center.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/explorer-sidebar.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/pair-link-modal.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
"packages/app/src/components/quitting-overlay.tsx",
|
||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||
"packages/app/src/components/resize-handle.tsx",
|
||||
"packages/app/src/components/rewind/rewind-menu.tsx",
|
||||
"packages/app/src/components/settings-textarea.tsx",
|
||||
"packages/app/src/components/sidebar-callout.tsx",
|
||||
"packages/app/src/components/split-container.tsx",
|
||||
"packages/app/src/components/split-drop-zone.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/ui/alert.tsx",
|
||||
"packages/app/src/components/ui/autocomplete.tsx",
|
||||
"packages/app/src/components/ui/combobox.tsx",
|
||||
"packages/app/src/components/ui/context-menu.tsx",
|
||||
"packages/app/src/components/ui/dropdown-menu.tsx",
|
||||
"packages/app/src/components/ui/external-link.tsx",
|
||||
"packages/app/src/components/volume-meter.tsx",
|
||||
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/composer/agent-controls/index.tsx",
|
||||
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
||||
"packages/app/src/constants/layout.ts",
|
||||
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
||||
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
||||
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
||||
"packages/app/src/desktop/components/integrations-section.tsx",
|
||||
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
||||
"packages/app/src/git/actions-split-button.tsx",
|
||||
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
||||
"packages/app/src/hosts/host-chooser.tsx",
|
||||
"packages/app/src/screens/open-project-screen.tsx",
|
||||
"packages/app/src/screens/projects-screen.tsx",
|
||||
"packages/app/src/screens/sessions-screen.tsx",
|
||||
"packages/app/src/screens/settings-screen.tsx",
|
||||
"packages/app/src/screens/settings/host-page.tsx",
|
||||
"packages/app/src/screens/settings/providers-section.tsx",
|
||||
"packages/app/src/screens/settings/settings-group.tsx",
|
||||
"packages/app/src/screens/startup-splash-screen.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@tanstack/react-query",
|
||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/file-explorer-pane.tsx",
|
||||
"packages/app/src/components/file-pane.tsx",
|
||||
"packages/app/src/components/import-session-sheet.tsx",
|
||||
"packages/app/src/components/project-picker-modal.tsx",
|
||||
"packages/app/src/desktop/components/pair-device-section.tsx",
|
||||
"packages/app/src/screens/new-workspace-screen.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/e2e/fixtures.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
java 21
|
||||
android-sdk latest
|
||||
android-sdk 21.0
|
||||
|
||||
144
CHANGELOG.md
144
CHANGELOG.md
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.109 - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
## 0.1.108 - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- Create a new project folder or clone a GitHub repository from Add Project ([#1331](https://github.com/getpaseo/paseo/pull/1331), [#2045](https://github.com/getpaseo/paseo/pull/2045), [#2097](https://github.com/getpaseo/paseo/pull/2097) by [@mcowger](https://github.com/mcowger))
|
||||
- Search for and open workspaces from the search menu ([#2096](https://github.com/getpaseo/paseo/pull/2096))
|
||||
- Pin workspaces to the top of the sidebar ([#1981](https://github.com/getpaseo/paseo/pull/1981) by [@half144](https://github.com/half144))
|
||||
- Summarize tool calls in a single collapsed item with a new appearance setting ([#2031](https://github.com/getpaseo/paseo/pull/2031), [#2069](https://github.com/getpaseo/paseo/pull/2069), [#2090](https://github.com/getpaseo/paseo/pull/2090) by [@mcowger](https://github.com/mcowger))
|
||||
- Save browser cookies and site data across tabs and restarts ([#2089](https://github.com/getpaseo/paseo/pull/2089))
|
||||
- Claude and Codex subagents now show their actual names, with a new option to archive finished Claude Code, Codex, and OpenCode subagents ([#2073](https://github.com/getpaseo/paseo/pull/2073))
|
||||
- Fork chats from failed turns ([#2063](https://github.com/getpaseo/paseo/pull/2063))
|
||||
|
||||
### Improved
|
||||
|
||||
- Permission modes have clearer icons ([#1980](https://github.com/getpaseo/paseo/pull/1980) by [@cleiter](https://github.com/cleiter))
|
||||
- Desktop stays usable in narrower windows ([#1983](https://github.com/getpaseo/paseo/pull/1983))
|
||||
- Sidebar controls stay in place when desktop panels open and close ([#2078](https://github.com/getpaseo/paseo/pull/2078))
|
||||
- Typing in long drafts is smoother ([#2086](https://github.com/getpaseo/paseo/pull/2086))
|
||||
- Codex terminal commands always appear in chat, even when they have no output ([#2037](https://github.com/getpaseo/paseo/pull/2037))
|
||||
|
||||
### Fixed
|
||||
|
||||
- New Workspace keeps your prompt and attachments when you switch projects or hosts ([#2030](https://github.com/getpaseo/paseo/pull/2030), [#2036](https://github.com/getpaseo/paseo/pull/2036))
|
||||
- OpenCode sessions close without crashing Paseo ([#2027](https://github.com/getpaseo/paseo/pull/2027) by [@mcowger](https://github.com/mcowger))
|
||||
- Pi slash commands no longer leave chats stuck as running ([#2066](https://github.com/getpaseo/paseo/pull/2066) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Background-agent updates now appear after the main reply ([#2058](https://github.com/getpaseo/paseo/pull/2058) by [@1254087415](https://github.com/1254087415))
|
||||
- Codex subagents no longer disappear from the Subagents track ([#2068](https://github.com/getpaseo/paseo/pull/2068))
|
||||
- Forked chats open ready to edit in their new tab ([#2038](https://github.com/getpaseo/paseo/pull/2038))
|
||||
- Paseo Desktop opens normally after an interrupted shutdown ([#1962](https://github.com/getpaseo/paseo/pull/1962))
|
||||
- Keyboard shortcuts now work with `-`, `=`, `;`, and `'` ([#2047](https://github.com/getpaseo/paseo/pull/2047) by [@OnCloud125252](https://github.com/OnCloud125252))
|
||||
- Codebuddy Code models now appear in the model picker ([#1979](https://github.com/getpaseo/paseo/pull/1979) by [@park0er](https://github.com/park0er))
|
||||
- Workspace search now includes OpenCode commands and workflows ([#2049](https://github.com/getpaseo/paseo/pull/2049))
|
||||
- Nix installations now include the Paseo web app ([#1978](https://github.com/getpaseo/paseo/pull/1978) by [@liamdiprose](https://github.com/liamdiprose))
|
||||
|
||||
## 0.1.107 - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- Inspect provider-created subagents and their live conversations from the Subagents track ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet))
|
||||
- Fork chats with every supported agent provider ([#2022](https://github.com/getpaseo/paseo/pull/2022))
|
||||
|
||||
### Improved
|
||||
|
||||
- Add projects directly from New Workspace when none are configured ([#2026](https://github.com/getpaseo/paseo/pull/2026))
|
||||
- New terminals open at the correct size immediately ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter))
|
||||
- Sidebar footer actions now explain themselves with tooltips ([#2025](https://github.com/getpaseo/paseo/pull/2025))
|
||||
- Codex shell tool calls show only the command being run ([#2029](https://github.com/getpaseo/paseo/pull/2029))
|
||||
- Custom ACP providers keep file and terminal work in the agent environment by default ([#2024](https://github.com/getpaseo/paseo/pull/2024))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Large tables no longer make iOS chats unresponsive
|
||||
- Chat controls remain clickable near the scroll-to-bottom button ([#2007](https://github.com/getpaseo/paseo/pull/2007))
|
||||
- Oversized tool output no longer slows or floods chat timelines ([#2020](https://github.com/getpaseo/paseo/pull/2020))
|
||||
- Cross-provider subagents can use providers without mode settings ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi's internal metadata tasks no longer clutter normal session history ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi chats remain usable after canceling extension commands ([#2019](https://github.com/getpaseo/paseo/pull/2019))
|
||||
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
||||
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
### Improved
|
||||
|
||||
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
||||
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
||||
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
||||
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
||||
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
||||
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
||||
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
||||
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
||||
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
||||
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
||||
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
||||
|
||||
## 0.1.104 - 2026-07-08
|
||||
|
||||
### 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), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
|
||||
|
||||
### Improved
|
||||
|
||||
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
|
||||
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
|
||||
- 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))
|
||||
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
|
||||
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
|
||||
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
|
||||
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
|
||||
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
|
||||
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
|
||||
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
|
||||
- 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
|
||||
|
||||
65
CLAUDE.md
65
CLAUDE.md
@@ -21,34 +21,38 @@ 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/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/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/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 |
|
||||
| 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/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [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/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [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
|
||||
|
||||
@@ -91,10 +95,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.
|
||||
|
||||
@@ -70,7 +70,7 @@ You need at least one agent CLI installed and configured with your credentials:
|
||||
|
||||
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
||||
|
||||
To connect from your phone, scan the QR code shown in Settings.
|
||||
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
|
||||
|
||||
### CLI / headless
|
||||
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM ${NODE_IMAGE}
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
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 \
|
||||
@@ -29,13 +52,14 @@ RUN set -eux; \
|
||||
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 --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
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"; \
|
||||
@@ -66,7 +90,7 @@ RUN set -eux; \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY rootfs/ /
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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; \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
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 \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional /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"]
|
||||
@@ -12,6 +12,10 @@ initializing → idle → running → idle (or error → closed)
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||
@@ -48,6 +52,12 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
|
||||
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
|
||||
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
@@ -61,23 +71,33 @@ Closing a tab on a **root agent** still archives — the tab is the agent's home
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
||||
|
||||
## Workspace activity
|
||||
|
||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||
|
||||
Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
||||
|
||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||
|
||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
@@ -97,7 +117,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
|
||||
@@ -13,6 +13,50 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
## Version codes
|
||||
|
||||
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
|
||||
|
||||
```text
|
||||
major * 1_000_000 + minor * 1_000 + patch
|
||||
```
|
||||
|
||||
Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. Do not re-enable EAS remote version counters or Android `autoIncrement`; F-Droid and other source-based builders need the native build number to be visible in the repo.
|
||||
|
||||
The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release.
|
||||
|
||||
## Prerequisites (local dev)
|
||||
|
||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
|
||||
|
||||
```bash
|
||||
mise install # java 21 + android-sdk 21.0 command-line tools
|
||||
```
|
||||
|
||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
|
||||
|
||||
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
|
||||
|
||||
```bash
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
|
||||
"system-images;android-35;google_apis;arm64-v8a"
|
||||
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
|
||||
emulator @paseo # start it; leave running
|
||||
```
|
||||
|
||||
On an Intel Mac, use the `x86_64` system image:
|
||||
|
||||
```bash
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
|
||||
"system-images;android-35;google_apis;x86_64"
|
||||
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
|
||||
emulator @paseo # start it; leave running
|
||||
```
|
||||
|
||||
Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust `android-35` only if it asks for a different level.
|
||||
|
||||
## Local build + install
|
||||
|
||||
From repo root:
|
||||
@@ -38,6 +82,46 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Running on an emulator against a worktree daemon
|
||||
|
||||
`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both:
|
||||
|
||||
```bash
|
||||
REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
|
||||
npm run android
|
||||
```
|
||||
|
||||
- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /<lan-ip>:8081` before any JS loads.
|
||||
- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port>`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take.
|
||||
|
||||
**Alternative — `adb reverse` + `localhost`** (if `10.0.2.2` misbehaves):
|
||||
|
||||
```bash
|
||||
adb reverse tcp:8081 tcp:8081
|
||||
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
|
||||
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
|
||||
npm run android
|
||||
```
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## F-Droid / source-only Android builds
|
||||
|
||||
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
|
||||
cd android
|
||||
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
|
||||
|
||||
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
@@ -53,13 +137,20 @@ adb exec-out screencap -p > screenshot.png
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (locally built, production-signed APK asset on GitHub Release). The workflow runs Expo prebuild and Gradle directly on the GitHub runner, so it does not consume an EAS cloud build.
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores.
|
||||
|
||||
The GitHub APK workflow also supports `workflow_dispatch`:
|
||||
|
||||
- Leave `publish` disabled and optionally provide `ref` to build any branch, tag, or commit as a workflow artifact without changing a GitHub release.
|
||||
- Enable `publish` and provide an existing `tag` to rebuild that tag and upload the APK to its GitHub release. Manual publishes always build the tag itself; they cannot publish an arbitrary ref under a release tag.
|
||||
|
||||
The workflow restores the production keystore and Firebase configuration from protected GitHub repository secrets, configures the production EAS Update channel, and verifies the finished APK's signing certificate before upload. Keep the protected values synchronized with the production Android credentials in EAS. The AAB profile continues using EAS-managed credentials and store submission.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -138,7 +138,9 @@ 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.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. 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.
|
||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
||||
|
||||
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -185,6 +187,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
||||
typed `entries` list.
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
80
docs/browser-capture-harness.md
Normal file
80
docs/browser-capture-harness.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Run the shared browser profile fixture with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The browser profile group runs two Electron processes in sequence. It verifies that each
|
||||
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
|
||||
tabs share cookies and local storage through one persistent session, and that the data is
|
||||
still present after the first Electron process exits and the second starts.
|
||||
|
||||
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.
|
||||
@@ -47,6 +47,8 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions).
|
||||
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
|
||||
|
||||
## Density
|
||||
|
||||
@@ -84,6 +86,10 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
| `MiniMax-M3` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
@@ -457,6 +457,37 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
}
|
||||
```
|
||||
|
||||
ACP agents execute filesystem and terminal operations in their own environment
|
||||
by default. To let a compliant agent delegate those operations to Paseo instead,
|
||||
enable the corresponding client capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"local-agent": {
|
||||
"extends": "acp",
|
||||
"label": "Local Agent",
|
||||
"command": ["local-agent", "acp"],
|
||||
"params": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": true,
|
||||
"writeTextFile": true
|
||||
},
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only enable capabilities Paseo should execute. When the agent and Paseo run in
|
||||
different environments, configure equivalent absolute workspace paths before
|
||||
delegating filesystem or terminal operations to Paseo.
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
@@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
## Store Surface Rules
|
||||
|
||||
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
@@ -195,9 +199,6 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
|
||||
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
@@ -419,15 +420,16 @@ Single file containing an array of all loop records. Writes are direct (not atom
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | ---------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
@@ -454,6 +456,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
|
||||
|
||||
@@ -131,6 +131,10 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
@@ -218,7 +222,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -59,11 +59,42 @@ startup routing, remembered workspace restore, or active workspace selection.
|
||||
|
||||
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
||||
|
||||
**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install:
|
||||
|
||||
- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service.
|
||||
- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist.
|
||||
- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step.
|
||||
|
||||
`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above.
|
||||
|
||||
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
|
||||
|
||||
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
|
||||
|
||||
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
|
||||
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
|
||||
|
||||
**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`.
|
||||
|
||||
### Running the iOS app on a local simulator
|
||||
|
||||
For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly:
|
||||
|
||||
```bash
|
||||
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app
|
||||
```
|
||||
|
||||
`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream).
|
||||
|
||||
**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server`
|
||||
```
|
||||
|
||||
The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the host daemon directly.
|
||||
|
||||
**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
@@ -72,6 +103,17 @@ 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.
|
||||
|
||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
||||
transition, and 751-pixel settings split with:
|
||||
|
||||
```bash
|
||||
npm run verify:electron-cdp --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The verifier reads the same `EXPO_PORT` and
|
||||
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when
|
||||
testing an isolated instance on non-default ports.
|
||||
|
||||
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.
|
||||
@@ -173,11 +215,33 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Agent Tool Catalog Measurement
|
||||
|
||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
||||
|
||||
```bash
|
||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
||||
for machine-readable output.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`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": {
|
||||
@@ -281,6 +345,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
||||
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
@@ -314,6 +380,7 @@ npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
The official image:
|
||||
|
||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||
or from source-built workspace tarballs for beta images
|
||||
- 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`
|
||||
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -t paseo:local docker/base
|
||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
||||
```
|
||||
|
||||
To bake a specific published npm version:
|
||||
To assert the source tree version while building:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
docker/base
|
||||
-f docker/base/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
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`.
|
||||
|
||||
|
||||
@@ -48,8 +48,13 @@ 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.
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
||||
is authoritative. Callers should not choose between separate route and tab
|
||||
navigation APIs.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
@@ -96,6 +101,18 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
@@ -121,7 +138,8 @@ 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 a route return to a workspace? Use `navigateToWorkspace()` and pass a
|
||||
`target` when the action names a specific tab.
|
||||
- [ ] 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
|
||||
|
||||
@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||
until then prefer copying the closest file and trimming.
|
||||
|
||||
## Popover width contract
|
||||
|
||||
Combobox desktop popovers are never narrower than their trigger, and they grow
|
||||
with content up to a ceiling that is never below the trigger:
|
||||
|
||||
```ts
|
||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
||||
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
||||
```
|
||||
|
||||
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
||||
trigger still wins when it is wider. Changing this default requires re-verifying
|
||||
every consumer listed here.
|
||||
|
||||
Consumers: `composer/agent-controls/mode-control.tsx`,
|
||||
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
||||
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
||||
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
||||
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
||||
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
||||
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
||||
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
||||
|
||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||
|
||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||
|
||||
98
docs/forms.md
Normal file
98
docs/forms.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Forms
|
||||
|
||||
The paved road for building forms in the app. The schedule form is the golden
|
||||
example; when building or fixing any form, copy its shape, not the shape of
|
||||
whatever screen you happen to be near.
|
||||
|
||||
Golden example files:
|
||||
|
||||
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
|
||||
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
|
||||
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
|
||||
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
|
||||
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
|
||||
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
|
||||
|
||||
## The form model
|
||||
|
||||
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
|
||||
|
||||
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
|
||||
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
|
||||
AND display from the snapshot — never from a previous instance.
|
||||
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
|
||||
Derived state (disclosure, canSubmit, displays) is recomputed inside the
|
||||
model on every publish.
|
||||
- `close()` destroys the instance. `subscribe`/`getState` feed one
|
||||
`useSyncExternalStore` in the component.
|
||||
|
||||
The component renders state and dispatches intent. That is all it does.
|
||||
|
||||
### Lifecycle rules (each one killed a real shipped bug)
|
||||
|
||||
1. **Fresh mount per open.** The sheet returns `null` when not visible and
|
||||
mounts the open form with a `key` derived from mode + record identity.
|
||||
A long-lived component instance shared across create/edit is how edit
|
||||
contaminated create.
|
||||
2. **Construct the model ONCE per mount** — `useState(() => openXxxForm(snapshot))`.
|
||||
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
|
||||
on live data (projects, hosts, preferences), and any background churn — e.g.
|
||||
a scheduled run creating a workspace — would reconstruct the model and wipe
|
||||
the user's in-progress input.
|
||||
3. **Late data is an explicit model input, not a reconstruction.**
|
||||
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
|
||||
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
|
||||
effects. Input plumbing is fine; orchestration effects are not — the sheet
|
||||
itself has zero `useEffect`/`useRef`, and that is the target for every form.
|
||||
4. **Resolution is explicit model state, per host** (`idle | pending |
|
||||
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
||||
state you can render, not an effect race.
|
||||
5. **Displays are owned state.** The selected option's label is captured at
|
||||
selection/seed time (`setProject(value, display)`), never re-derived from a
|
||||
live options list — list churn must not flicker or blank a selection.
|
||||
6. **Disclosure is derived in the model** from user intent
|
||||
(host → project → model → thinking/mode), so fields cannot pop in from
|
||||
cache timing.
|
||||
|
||||
## Form kit
|
||||
|
||||
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
|
||||
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
|
||||
states) is owned by `components/ui/control-geometry.ts` — controls never
|
||||
declare their own, and screens never nudge global component styles to align
|
||||
a row.
|
||||
- The form declares one size for all fields: `sm` on desktop, `md` compact
|
||||
(`useIsCompactFormFactor`).
|
||||
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
|
||||
(isolation on a non-git project — same gating as New Workspace), not rendered
|
||||
disabled with an explanation. Disabled-with-a-reason `hint` is only for
|
||||
transient states the user can resolve.
|
||||
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
|
||||
exact string; validation errors are the exception. State a fact (like the
|
||||
timezone) once — never in a preview line AND a helper line.
|
||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
||||
|
||||
## Data gating
|
||||
|
||||
Aggregate hooks return a discriminated load state:
|
||||
|
||||
```ts
|
||||
type AggregateLoadState<T> =
|
||||
| { status: "connecting" } // an answer may still be pending
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; data: T[] };
|
||||
```
|
||||
|
||||
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
|
||||
before hosts connected is `connecting`, not empty. Query keys carry real fetch
|
||||
inputs (host set, connection statuses), never synthetic version counters.
|
||||
|
||||
## Anti-patterns (reject in review on sight)
|
||||
|
||||
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
|
||||
- One mounted form instance serving create and edit.
|
||||
- `useMemo`-keyed model construction on live-data identity.
|
||||
- Selected labels derived from live query lists.
|
||||
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
|
||||
- Conditional mounting of hint/error rows that shifts layout (subtext renders
|
||||
only when present, but the pattern for that lives in `Field`, not ad hoc).
|
||||
@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
|
||||
107
docs/mobile-panels.md
Normal file
107
docs/mobile-panels.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Mobile panels
|
||||
|
||||
Compact layouts have three mutually exclusive destinations:
|
||||
|
||||
- `agent-list` on the left
|
||||
- `agent` in the center
|
||||
- `file-explorer` on the right
|
||||
|
||||
They are one interaction, not two independent drawers. The implementation lives in
|
||||
`packages/app/src/mobile-panels/`.
|
||||
|
||||
## Ownership
|
||||
|
||||
React/Zustand owns the durable intent:
|
||||
|
||||
```ts
|
||||
interface MobilePanelSelection {
|
||||
target: "agent-list" | "agent" | "file-explorer";
|
||||
revision: number;
|
||||
}
|
||||
```
|
||||
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
The UI worklet owns transient motion:
|
||||
|
||||
- one normalized position (`-1` left, `0` center, `1` right)
|
||||
- the current motion target
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
|
||||
## Why one position
|
||||
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
- a panel and its backdrop disagreeing
|
||||
- left and right drawers both claiming to be open
|
||||
- a width-sync effect resetting an active drag
|
||||
- one animation context settling a transition owned by the other
|
||||
|
||||
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
||||
effect.
|
||||
|
||||
## Ordering and interruption
|
||||
|
||||
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
||||
while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||
its target and revision still match the canonical command.
|
||||
|
||||
Manual gesture arbitration has two phases:
|
||||
|
||||
1. Before activation, determine whether horizontal intent may begin.
|
||||
2. After activation, stop running begin checks and let the active revision own updates.
|
||||
|
||||
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
||||
definition, no longer eligible to begin.
|
||||
|
||||
## Integration rules
|
||||
|
||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||
styles in sidebar components.
|
||||
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
|
||||
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
|
||||
removing the sidebar tree.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||
sequence there whenever ownership or ordering changes.
|
||||
@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
|
||||
done
|
||||
```
|
||||
|
||||
### Android audio focus interruptions
|
||||
|
||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||
|
||||
```bash
|
||||
adb shell am start -n sh.paseo/.MainActivity
|
||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||
adb emu gsm call 5551234
|
||||
# Foreground Paseo while the call is still ringing.
|
||||
```
|
||||
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
|
||||
@@ -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,13 +18,17 @@ 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.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ 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`, `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 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`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -186,6 +188,8 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
|
||||
|
||||
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
@@ -281,8 +285,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
|
||||
@@ -21,6 +21,18 @@ WRONG (horizontal):
|
||||
|
||||
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
|
||||
|
||||
## Fallible user actions
|
||||
|
||||
Every user action that can fail must expose the complete operation state in the UI:
|
||||
|
||||
- **Pending:** show immediate progress and prevent accidental duplicate submissions.
|
||||
- **Success:** show the requested result, or a clear success acknowledgement when the result is not otherwise visible.
|
||||
- **Failure:** keep an actionable error visible in the same context until the user retries or dismisses it.
|
||||
|
||||
Logs, console output, and a reset button are not user feedback. Neither is a platform API unless it is verified on every supported platform: React Native Web's `Alert.alert()` is a no-op, so browser and Electron failures must use rendered app UI such as the shared alert component.
|
||||
|
||||
Every fallible action needs behavioral coverage for success and failure. RPC-backed UI should use an app Playwright test with a real browser, network, and daemon whenever feasible. The failure test must assert what the user can see and do after the failure, not an internal response, state field, or log line. Add distinct timeout or disconnect cases when they produce distinct recovery behavior.
|
||||
|
||||
## Determinism first
|
||||
|
||||
Tests must produce the same result every run:
|
||||
@@ -91,6 +103,27 @@ function createTestEmailSender() {
|
||||
|
||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||
|
||||
### Packaged desktop smoke
|
||||
|
||||
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
|
||||
|
||||
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
|
||||
- the `paseo://app/` renderer mounts into `#root`;
|
||||
- the sandboxed preload exposes the desktop bridge;
|
||||
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||
- the bundled CLI can query that daemon and run a terminal command.
|
||||
|
||||
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
|
||||
|
||||
To exercise the smoke locally on Linux:
|
||||
|
||||
```bash
|
||||
PASEO_DESKTOP_SMOKE=1 \
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
|
||||
npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
```
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
@@ -115,6 +148,8 @@ App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
|
||||
@@ -9,6 +9,11 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
@@ -32,6 +37,14 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
|
||||
|
||||
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.
|
||||
|
||||
## Durable item anchors
|
||||
|
||||
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
|
||||
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
|
||||
|
||||
@@ -90,6 +90,19 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||
|
||||
```tsx
|
||||
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||
<View style={styles.row} />;
|
||||
|
||||
// Right: one registered style identity reaches the native view.
|
||||
<View style={sharedStyles.row} />;
|
||||
```
|
||||
|
||||
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
|
||||
|
||||
@@ -30,9 +30,7 @@ buildNpmPackage rec {
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||
@@ -81,6 +79,7 @@ buildNpmPackage rec {
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
npm run build:server
|
||||
npm run build:daemon-web-ui
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
@@ -107,6 +106,9 @@ buildNpmPackage rec {
|
||||
# CLI/server bin starts from $out.
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Web UI Assets
|
||||
cp -r packages/server/dist/server/web-ui $out/lib/paseo/packages/server/dist/server/
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
|
||||
403
package-lock.json
generated
403
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -19591,6 +19591,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-gradle-jvmargs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-gradle-jvmargs/-/expo-gradle-jvmargs-1.1.2.tgz",
|
||||
"integrity": "sha512-VJRecrYlklVXEwafLyuZKUCnYEY9vJYvUy639LN3c5krlmunkYQphh/YzXDng8C9oihz6mr+cPbwEn6sv6fXyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@expo/config-plugins": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
|
||||
@@ -21797,9 +21807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28563,9 +28573,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35069,6 +35079,26 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-aot": {
|
||||
"version": "0.20.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
|
||||
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"get-tsconfig": "^4.14.0",
|
||||
"jiti": "^2.7.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"unplugin": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"zod-aot": "dist/cli/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
@@ -35132,7 +35162,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -35230,6 +35260,7 @@
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
@@ -36150,12 +36181,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/server": "0.1.103",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/server": "0.1.109",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36401,10 +36432,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36415,7 +36446,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36658,7 +36689,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37554,7 +37585,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37786,19 +37817,20 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38016,15 +38048,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/highlight": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38561,7 +38593,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -38588,7 +38620,8 @@
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@babel/code-frame": {
|
||||
@@ -39523,6 +39556,201 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/browser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
|
||||
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@blazediff/core": "1.9.1",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pngjs": "^7.0.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/browser-playwright": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
|
||||
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/browser/node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -39883,6 +40111,123 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/vitest/node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/website/node_modules/workerd": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -58,7 +58,7 @@
|
||||
"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,7 +1,75 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
? {
|
||||
androidPermissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
],
|
||||
cameraPlugins: [],
|
||||
fdroidPlugins: [withFdroidAutolinking],
|
||||
notificationPlugins: [],
|
||||
updates: { enabled: false },
|
||||
}
|
||||
: {
|
||||
androidPermissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"CAMERA",
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
cameraPlugins: [
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission:
|
||||
"Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
|
||||
},
|
||||
],
|
||||
],
|
||||
fdroidPlugins: [],
|
||||
notificationPlugins: [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
icon: "./assets/images/notification-icon.png",
|
||||
color: "#20744A",
|
||||
},
|
||||
],
|
||||
],
|
||||
updates: {},
|
||||
};
|
||||
|
||||
function getNativeBuildVersionCode(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
|
||||
}
|
||||
|
||||
const [, majorText, minorText, patchText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = Number(minorText);
|
||||
const patch = Number(patchText);
|
||||
|
||||
if (minor > 999 || patch > 999) {
|
||||
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
return versionCode;
|
||||
}
|
||||
|
||||
function resolveSecretFile(params) {
|
||||
const fromEnv = process.env[params.envKey];
|
||||
@@ -45,6 +113,7 @@ const variants = {
|
||||
};
|
||||
|
||||
const variant = variants[appVariant] ?? variants.production;
|
||||
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
|
||||
|
||||
export default {
|
||||
expo: {
|
||||
@@ -61,6 +130,14 @@ export default {
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
...(process.env.EXPO_UPDATES_CHANNEL
|
||||
? {
|
||||
requestHeaders: {
|
||||
"expo-channel-name": process.env.EXPO_UPDATES_CHANNEL,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...buildProfile.updates,
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
@@ -72,6 +149,7 @@ export default {
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
buildNumber: String(nativeBuildVersionCode),
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -83,14 +161,9 @@ export default {
|
||||
softwareKeyboardLayoutMode: "resize",
|
||||
// Allow HTTP connections for local network hosts (required for release builds)
|
||||
usesCleartextTraffic: true,
|
||||
permissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"CAMERA",
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
permissions: buildProfile.androidPermissions,
|
||||
package: variant.packageId,
|
||||
versionCode: nativeBuildVersionCode,
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
@@ -102,12 +175,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
|
||||
},
|
||||
],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -120,14 +188,15 @@ export default {
|
||||
},
|
||||
},
|
||||
],
|
||||
...buildProfile.notificationPlugins,
|
||||
"expo-audio",
|
||||
[
|
||||
"expo-notifications",
|
||||
"expo-gradle-jvmargs",
|
||||
{
|
||||
icon: "./assets/images/notification-icon.png",
|
||||
color: "#20744A",
|
||||
xmx: "4096m",
|
||||
maxMetaspace: "1024m",
|
||||
},
|
||||
],
|
||||
"expo-audio",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -139,6 +208,7 @@ export default {
|
||||
},
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -146,6 +216,7 @@ export default {
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
350
packages/app/e2e/add-project-flow.spec.ts
Normal file
350
packages/app/e2e/add-project-flow.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowHost,
|
||||
addProjectFlowInput,
|
||||
addProjectFlowMethod,
|
||||
chooseAddProjectMethod,
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
const SECONDARY_HOST_ID = "add-project-flow-secondary";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
|
||||
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
|
||||
const registryEntry = buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${host.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.evaluate(
|
||||
({ key, entry }) => {
|
||||
localStorage.setItem(key, JSON.stringify([entry]));
|
||||
},
|
||||
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function expectProjectDirectory(pathname: string): Promise<void> {
|
||||
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
|
||||
}
|
||||
|
||||
async function removeCreatedProject(
|
||||
pathname: string,
|
||||
knownProjectId: string | null,
|
||||
): Promise<void> {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
let projectId = knownProjectId;
|
||||
if (!projectId) {
|
||||
const result = await client.addProject(pathname);
|
||||
projectId = result.project?.projectId ?? null;
|
||||
}
|
||||
if (projectId) await client.removeProject(projectId).catch(() => undefined);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const result = await client.fetchWorkspaces({ filter: { projectId } });
|
||||
expect(result.entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a single connected host opens directly on method selection", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill("/tmp");
|
||||
|
||||
const backGlyph = addProjectFlowBack(page).locator("svg");
|
||||
const resultGlyph = addProjectFlow(page)
|
||||
.locator('[data-testid^="add-project-flow-path-"]')
|
||||
.first()
|
||||
.locator("svg");
|
||||
await expect(resultGlyph).toBeVisible();
|
||||
|
||||
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
|
||||
backGlyph.boundingBox(),
|
||||
addProjectFlowInput(page).boundingBox(),
|
||||
resultGlyph.boundingBox(),
|
||||
page.getByTestId("add-project-flow-title").boundingBox(),
|
||||
page.getByTestId("add-project-flow-results").boundingBox(),
|
||||
page.getByTestId("add-project-flow-footer").boundingBox(),
|
||||
]);
|
||||
expect(backBox).not.toBeNull();
|
||||
expect(inputBox).not.toBeNull();
|
||||
expect(resultBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(resultsBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
|
||||
|
||||
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(titleBox.height).toBeLessThanOrEqual(24);
|
||||
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
||||
});
|
||||
|
||||
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: "add-project-flow-offline",
|
||||
label: "Offline Host",
|
||||
});
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowHost(page, "add-project-flow-offline")).toHaveCount(0);
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("with two connected hosts", () => {
|
||||
let secondaryHost: IsolatedHostDaemon;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await secondaryHost?.close();
|
||||
});
|
||||
|
||||
test("keyboard selection chooses the second host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("Escape and Back restore page input and active selection before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
|
||||
await addProjectFlowInput(page).fill("o");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
await addProjectFlowInput(page).fill("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await addProjectFlowBack(page).click();
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await addProjectFlowBack(page).click();
|
||||
await expectAddProjectPage(page, "host");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue("o");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expectAddProjectPage(page, "host");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-remote-project-"));
|
||||
const directoryName = `remote-${randomUUID().slice(0, 8)}`;
|
||||
const directoryPath = path.join(parentDirectory, directoryName);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText(
|
||||
`Create an empty directory on ${SECONDARY_HOST_LABEL}`,
|
||||
);
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await addProjectFlowInput(page).fill(parentDirectory);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "new-directory-name");
|
||||
await page.keyboard.type(directoryName);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectId = await expectOpenedProject(page, directoryName);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
projectId,
|
||||
projectName: directoryName,
|
||||
projectPath: directoryPath,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText(SECONDARY_HOST_LABEL);
|
||||
await expectProjectDirectory(directoryPath);
|
||||
} finally {
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keyboard directory search adds the selected Project", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: projectPickerFixture.projectName,
|
||||
projectPath: projectPickerFixture.projectPath,
|
||||
});
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "github");
|
||||
|
||||
const remote = "https://github.invalid/acme/manual.git";
|
||||
await addProjectFlowInput(page).fill(remote);
|
||||
await expect(addProjectFlow(page).getByText("manual", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAddProjectPage(page, "github-location");
|
||||
const title = addProjectFlow(page).getByTestId("add-project-flow-title");
|
||||
await expect(title.getByText("Choose destination", { exact: true })).toBeVisible();
|
||||
await expect(title.getByText("localhost", { exact: true })).toBeVisible();
|
||||
await expect(title).not.toContainText("Where should Paseo create");
|
||||
await addProjectFlowBack(page).click();
|
||||
await expect(addProjectFlowInput(page)).toHaveValue(remote);
|
||||
});
|
||||
|
||||
test("New directory validates the name, restores parent and name state, then creates a Project", async ({
|
||||
page,
|
||||
}) => {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-new-project-"));
|
||||
const directoryName = `created-${randomUUID().slice(0, 8)}`;
|
||||
const directoryPath = path.join(parentDirectory, directoryName);
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
|
||||
await page.keyboard.type(parentDirectory);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "new-directory-name");
|
||||
await page.keyboard.type("../invalid");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const error = page.getByTestId("add-project-flow-error");
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toContainText(/name|separator|directory/i);
|
||||
await expectAddProjectPage(page, "new-directory-name");
|
||||
|
||||
await addProjectFlowInput(page).fill(directoryName);
|
||||
await addProjectFlowBack(page).click();
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "new-directory-name");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue(directoryName);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
projectId = await expectOpenedProject(page, directoryName);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: directoryName,
|
||||
projectPath: directoryPath,
|
||||
});
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
await expectProjectDirectory(directoryPath);
|
||||
} finally {
|
||||
await removeCreatedProject(directoryPath, projectId).catch(() => undefined);
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
96
packages/app/e2e/add-project-github.real.spec.ts
Normal file
96
packages/app/e2e/add-project-github.real.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowInput,
|
||||
chooseAddProjectMethod,
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.describe("Add Project GitHub flow", () => {
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("searches the host's repositories and clones into a clearly shown final path", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
let repository: GhRepoFixture | null = null;
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-github-clone-"));
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
repository = await createTempGithubRepo({ category: "add-project" });
|
||||
const checkoutPath = path.join(parentDirectory, repository.name);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "github");
|
||||
|
||||
await addProjectFlowInput(page).fill("getpaseo/paseo");
|
||||
await expect(addProjectFlow(page).getByText("getpaseo/paseo", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await addProjectFlowInput(page).fill("");
|
||||
|
||||
const repositoryRow = addProjectFlow(page).getByText(repository.fullName, { exact: true });
|
||||
await expect(repositoryRow).toBeVisible({ timeout: 30_000 });
|
||||
await repositoryRow.click();
|
||||
await expectAddProjectPage(page, "github-location");
|
||||
|
||||
await addProjectFlowInput(page).fill(parentDirectory);
|
||||
await expect(addProjectFlow(page)).toContainText(checkoutPath, { timeout: 30_000 });
|
||||
|
||||
await addProjectFlowBack(page).click();
|
||||
await expectAddProjectPage(page, "github-search");
|
||||
await expect(repositoryRow).toBeVisible();
|
||||
await repositoryRow.click();
|
||||
await expectAddProjectPage(page, "github-location");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory);
|
||||
|
||||
await mkdir(checkoutPath);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("add-project-flow-error")).toHaveText(
|
||||
`Checkout path already exists: ${checkoutPath}`,
|
||||
);
|
||||
|
||||
await rm(checkoutPath, { recursive: true });
|
||||
await page.keyboard.press("Enter");
|
||||
projectId = await expectOpenedProject(page, repository.name);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: repository.name,
|
||||
projectPath: checkoutPath,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
await expect.poll(async () => (await stat(checkoutPath)).isDirectory()).toBe(true);
|
||||
} finally {
|
||||
if (projectId) {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
await repository?.cleanup();
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
clickToolCallBesideScrollToBottomButton,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
@@ -37,6 +38,91 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -120,6 +206,37 @@ test.describe("Agent stream UI", () => {
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-button-hit-area-",
|
||||
title: "Scroll button hit area",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Stream enough content to exercise the scroll button hit area.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const hitArea = await clickToolCallBesideScrollToBottomButton(page);
|
||||
|
||||
expect(hitArea).toEqual({
|
||||
outsideButton: true,
|
||||
toolCallReceivesPointer: true,
|
||||
withinButtonBand: true,
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -123,7 +122,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -138,18 +137,17 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { submitNewWorkspaceEmpty } from "./helpers/new-workspace";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
@@ -53,14 +54,73 @@ async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
test("forks a failed assistant turn that has no provider message id", 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.",
|
||||
repoPrefix: "assistant-fork-failed-turn-",
|
||||
title: "Assistant fork failed turn",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "Emit a synthetic turn failure.");
|
||||
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("focuses a forked assistant turn in a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-focused-tab-",
|
||||
title: "Assistant fork focused tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for initial assistant fork turn.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-submit-",
|
||||
title: "Assistant fork tab submit",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
@@ -71,8 +131,13 @@ test.describe("Assistant fork menu", () => {
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await submitMessage(page, "");
|
||||
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
@@ -118,4 +183,31 @@ test.describe("Assistant fork menu", () => {
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("keeps the fork attachment after the new agent receives its user message", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-submit-",
|
||||
title: "Assistant fork submit",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork submit.",
|
||||
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 expectChatHistoryPill(page);
|
||||
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { expectAgentTabActive } from "./helpers/launcher";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
@@ -46,4 +47,26 @@ test.describe("Command center host labels", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
106
packages/app/e2e/command-center-workspaces.spec.ts
Normal file
106
packages/app/e2e/command-center-workspaces.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-workspaces-secondary";
|
||||
const WORKSPACE_TITLE = "Payments Refactor";
|
||||
const WORKSPACE_BRANCH = "feature/cmd-k-workspaces";
|
||||
const AGENT_TITLE = "Fix checkout retries";
|
||||
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("workspace results show their title, host, and branch and open the workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: AGENT_TITLE,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(WORKSPACE_TITLE);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toContainText(WORKSPACE_BRANCH);
|
||||
|
||||
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(agentRow).toContainText(AGENT_TITLE);
|
||||
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(agentRow).toContainText(WORKSPACE_TITLE);
|
||||
await expect(agentRow).not.toContainText(seeded.repoPath);
|
||||
|
||||
const workspaceSectionTop = await panel
|
||||
.getByText("Workspaces", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const agentSectionTop = await panel
|
||||
.getByText("Agents", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
|
||||
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_BRANCH);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(seeded.repoPath);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
await input.fill(AGENT_TITLE);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectFlatFileList(page);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("changes-toggle-whitespace").click();
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectFlatFileList(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addProjectFlowInput,
|
||||
chooseAddProjectMethod,
|
||||
openAddProjectFlow,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
@@ -10,7 +17,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -20,10 +27,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
@@ -48,10 +51,10 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
}
|
||||
|
||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
const input = addProjectFlowInput(page);
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -74,19 +77,39 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("opens a project from a fuzzy directory-name search", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
|
||||
const input = addProjectFlowInput(page);
|
||||
await input.fill(projectPickerFixture.fuzzyQuery);
|
||||
|
||||
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 30_000 });
|
||||
await suggestion.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
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();
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
const input = addProjectFlowInput(page);
|
||||
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();
|
||||
await expect(page.getByText("Loading...", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,17 +161,21 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import {
|
||||
createProjectPickerFixture,
|
||||
removeProjectPickerFixture,
|
||||
type ProjectPickerFixture,
|
||||
} from "./helpers/project-picker-fixture";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||
rememberProjectId: (projectId: string) => void;
|
||||
}
|
||||
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -57,7 +76,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
@@ -73,12 +92,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
||||
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
{
|
||||
daemon: testDaemon,
|
||||
preferences: createAgentPreferences,
|
||||
seedNonce,
|
||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||
},
|
||||
);
|
||||
|
||||
await provide();
|
||||
@@ -92,6 +119,40 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
outdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon();
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
desktopManagedOutdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon({ desktopManaged: true });
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await provide({
|
||||
...fixture,
|
||||
rememberProjectId: (openedProjectId) => {
|
||||
projectId = openedProjectId;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
await removeProjectPickerFixture(client, fixture, projectId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await resource.removeDirectory();
|
||||
}
|
||||
}
|
||||
},
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
|
||||
95
packages/app/e2e/helpers/add-project-flow.ts
Normal file
95
packages/app/e2e/helpers/add-project-flow.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export type AddProjectFlowPage =
|
||||
| "host"
|
||||
| "method"
|
||||
| "directory-search"
|
||||
| "github-search"
|
||||
| "github-location"
|
||||
| "new-directory-parent"
|
||||
| "new-directory-name";
|
||||
|
||||
export type AddProjectMethod = "directory-search" | "browse" | "github" | "new-directory";
|
||||
|
||||
const METHOD_DESTINATIONS: Record<Exclude<AddProjectMethod, "browse">, AddProjectFlowPage> = {
|
||||
"directory-search": "directory-search",
|
||||
github: "github-search",
|
||||
"new-directory": "new-directory-parent",
|
||||
};
|
||||
|
||||
export function addProjectFlow(page: Page): Locator {
|
||||
return page.getByTestId("add-project-flow");
|
||||
}
|
||||
|
||||
export function addProjectFlowInput(page: Page): Locator {
|
||||
return page.getByTestId("add-project-flow-input");
|
||||
}
|
||||
|
||||
export function addProjectFlowBack(page: Page): Locator {
|
||||
return page.getByTestId("add-project-flow-back");
|
||||
}
|
||||
|
||||
export function addProjectFlowHost(page: Page, serverId: string): Locator {
|
||||
return page.getByTestId(`add-project-flow-host-${serverId}`);
|
||||
}
|
||||
|
||||
export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Locator {
|
||||
return page.getByTestId(`add-project-flow-method-${method}`);
|
||||
}
|
||||
|
||||
export async function waitForConnectedHost(
|
||||
page: Page,
|
||||
input: { serverId: string; endpoint: string },
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
|
||||
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
|
||||
await expect(currentPage).toBeVisible({ timeout: 30_000 });
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method" = "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function chooseAddProjectMethod(page: Page, method: AddProjectMethod): Promise<void> {
|
||||
const option = addProjectFlowMethod(page, method);
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
if (method !== "browse") {
|
||||
await expectAddProjectPage(page, METHOD_DESTINATIONS[method]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceForAddedProject(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe("/new");
|
||||
expect(url.searchParams.get("serverId")).toBe(input.serverId);
|
||||
expect(url.searchParams.get("projectId")).toBe(input.projectId);
|
||||
expect(url.searchParams.get("dir")).toBe(input.projectPath);
|
||||
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
|
||||
input.projectName,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,111 @@ export async function scrollChatAwayFromBottom(
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{
|
||||
outsideButton: boolean;
|
||||
toolCallReceivesPointer: boolean;
|
||||
withinButtonBand: boolean;
|
||||
}> {
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" });
|
||||
await expect(scrollToBottomButton).toBeVisible();
|
||||
|
||||
const buttonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull();
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
expect(
|
||||
candidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds,
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
visibleToolCallBounds,
|
||||
"Expected aligned tool-call badge to remain visible",
|
||||
).not.toBeNull();
|
||||
const finalButtonBounds = alignedButtonBounds!;
|
||||
const finalToolCallBounds = visibleToolCallBounds!;
|
||||
|
||||
const clickPoint = {
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
const hitArea = {
|
||||
clickPoint,
|
||||
outsideButton:
|
||||
clickPoint.x < finalButtonBounds.x ||
|
||||
clickPoint.x > finalButtonBounds.x + finalButtonBounds.width,
|
||||
toolCallReceivesPointer,
|
||||
withinButtonBand:
|
||||
clickPoint.y >= finalButtonBounds.y &&
|
||||
clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height,
|
||||
};
|
||||
await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y);
|
||||
return {
|
||||
outsideButton: hitArea.outsideButton,
|
||||
toolCallReceivesPointer: hitArea.toolCallReceivesPointer,
|
||||
withinButtonBand: hitArea.withinButtonBand,
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface IdleAgentSeedClient {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
@@ -58,7 +59,11 @@ export async function createIdleAgent(
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
@@ -98,12 +103,6 @@ export async function fetchAgentArchivedAt(
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
export function getWorktreeRestoreFeature(client: {
|
||||
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
||||
}): boolean {
|
||||
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
93
packages/app/e2e/helpers/daemon-update.ts
Normal file
93
packages/app/e2e/helpers/daemon-update.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
serverId: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonReadyMessage {
|
||||
type: "ready";
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonErrorMessage {
|
||||
type: "error";
|
||||
error: string;
|
||||
}
|
||||
|
||||
type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMessage;
|
||||
|
||||
export async function startOutdatedDaemon(options?: {
|
||||
desktopManaged?: boolean;
|
||||
}): Promise<OutdatedDaemon> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error("E2E_METRO_PORT is not set - globalSetup must run first");
|
||||
}
|
||||
|
||||
const child = fork(
|
||||
path.resolve(__dirname, "../../../server/src/server/test-utils/outdated-daemon-process.ts"),
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_METRO_PORT: metroPort,
|
||||
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
|
||||
},
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
},
|
||||
);
|
||||
const stderr: string[] = [];
|
||||
child.stderr?.on("data", (data: Buffer) => stderr.push(data.toString("utf8")));
|
||||
|
||||
try {
|
||||
const ready = await waitForDaemon(child, stderr);
|
||||
return {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(
|
||||
child: ChildProcess,
|
||||
stderr: string[],
|
||||
): Promise<OutdatedDaemonReadyMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out starting outdated daemon. ${stderr.join("")}`));
|
||||
}, 20_000);
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`Outdated daemon exited before startup (code ${String(code)}, signal ${String(signal)}). ${stderr.join("")}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.once("message", (message: OutdatedDaemonMessage) => {
|
||||
if (message.type === "error") {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
dialogOpenResult?: string | string[] | null;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
@@ -79,13 +80,15 @@ interface DesktopEditorTargetConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
|
||||
}
|
||||
|
||||
interface DesktopEditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
@@ -96,6 +99,7 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -157,6 +161,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
@@ -249,6 +254,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
open: async (options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogOpenCalls.push(options);
|
||||
return cfg.dialogOpenResult ?? null;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
@@ -263,10 +272,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
window.__capturedDialogOpenCalls = [];
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
||||
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
129
packages/app/e2e/helpers/isolated-host-daemon.ts
Normal file
129
packages/app/e2e/helpers/isolated-host-daemon.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { once } from "node:events";
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire an isolated daemon port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1_000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to isolated daemon port ${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Isolated host daemon did not listen on ${port}: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(serverId: string): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
|
||||
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
close: async () => {
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
>;
|
||||
@@ -181,6 +183,21 @@ export async function expectNewWorkspaceProjectSelected(
|
||||
await expect(projectPicker).toContainText(projectDisplayName);
|
||||
}
|
||||
|
||||
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(draft);
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue(draft);
|
||||
}
|
||||
|
||||
export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> {
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByText(hostLabel, { exact: true }).click();
|
||||
}
|
||||
|
||||
export async function submitNewWorkspacePrompt(
|
||||
page: Page,
|
||||
prompt = "Hello from e2e",
|
||||
|
||||
61
packages/app/e2e/helpers/no-truncation.ts
Normal file
61
packages/app/e2e/helpers/no-truncation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
interface TruncatedLabel {
|
||||
text: string;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
}
|
||||
|
||||
export async function expectNoTruncation(locator: Locator): Promise<void> {
|
||||
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
|
||||
function isVisible(element: HTMLElement): boolean {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
function directText(element: HTMLElement): string {
|
||||
return Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const checked = new Set<HTMLElement>();
|
||||
const failures: TruncatedLabel[] = [];
|
||||
|
||||
for (const root of elements) {
|
||||
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
|
||||
continue;
|
||||
}
|
||||
checked.add(candidate);
|
||||
|
||||
const text = directText(candidate);
|
||||
if (!text) continue;
|
||||
if (candidate.scrollWidth <= candidate.clientWidth) continue;
|
||||
|
||||
failures.push({
|
||||
text,
|
||||
scrollWidth: candidate.scrollWidth,
|
||||
clientWidth: candidate.clientWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
});
|
||||
|
||||
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
|
||||
}
|
||||
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SeedDaemonClient } from "./seed-client";
|
||||
|
||||
export interface ProjectPickerFixture {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
fuzzyQuery: string;
|
||||
}
|
||||
|
||||
interface ProjectPickerFixtureResource {
|
||||
fixture: ProjectPickerFixture;
|
||||
removeDirectory: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
|
||||
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
|
||||
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
|
||||
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
|
||||
return {
|
||||
fixture: {
|
||||
projectPath,
|
||||
projectName: path.basename(projectPath),
|
||||
fuzzyQuery: `psodfzt${nonce}`,
|
||||
},
|
||||
removeDirectory: () => rm(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeProjectPickerFixture(
|
||||
client: SeedDaemonClient,
|
||||
fixture: ProjectPickerFixture,
|
||||
knownProjectId: string | null = null,
|
||||
): Promise<void> {
|
||||
let projectId = knownProjectId;
|
||||
if (!projectId) {
|
||||
const lookup = await client.addProject(fixture.projectPath);
|
||||
projectId = lookup.project?.projectId ?? null;
|
||||
if (!projectId) {
|
||||
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
|
||||
}
|
||||
}
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectName })
|
||||
.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-", "");
|
||||
}
|
||||
@@ -154,6 +154,10 @@ export async function launchAgent(input: {
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
providerConfig?: {
|
||||
model?: string;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
@@ -180,6 +184,7 @@ export async function launchAgent(input: {
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
...input.providerConfig,
|
||||
cwd: input.cwd,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
|
||||
@@ -5,10 +5,10 @@ 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";
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
export const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
@@ -20,6 +20,31 @@ export interface FakeScheduleHostWorkspace {
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FakeScheduleSummary {
|
||||
id: string;
|
||||
name: string | null;
|
||||
prompt: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
status: "active";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
pausedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
maxRuns: number | null;
|
||||
}
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -114,6 +139,7 @@ export async function installFakeScheduleHost(input: {
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
schedules?: FakeScheduleSummary[];
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
@@ -130,7 +156,7 @@ export async function installFakeScheduleHost(input: {
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
workspaceRecovery: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -201,7 +227,7 @@ export async function installFakeScheduleHost(input: {
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: [],
|
||||
schedules: input.schedules ?? [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
@@ -232,15 +258,24 @@ export async function addFakeScheduleHostAndReload(input: {
|
||||
}
|
||||
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);
|
||||
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||
nextRegistry.push(seededHost);
|
||||
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
|
||||
|
||||
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
|
||||
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
|
||||
? JSON.parse(rawExtraHosts)
|
||||
: [];
|
||||
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||
nextExtraHosts.push(seededHost);
|
||||
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
extraHosts: EXTRA_HOSTS_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,11 +132,15 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
@@ -183,6 +187,7 @@ export interface SeededWorkspace {
|
||||
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
title?: string;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
||||
@@ -196,6 +201,7 @@ export async function seedWorkspace(options: {
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
title: options.title,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
|
||||
@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
|
||||
86
packages/app/e2e/helpers/settled.ts
Normal file
86
packages/app/e2e/helpers/settled.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
interface SettledOptions {
|
||||
timeout?: number;
|
||||
durationMs?: number;
|
||||
intervalMs?: number;
|
||||
heightTolerance?: number;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
text: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_MS = 1_500;
|
||||
const DEFAULT_INTERVAL_MS = 100;
|
||||
const DEFAULT_HEIGHT_TOLERANCE = 1;
|
||||
|
||||
async function sample(locator: Locator): Promise<Sample> {
|
||||
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
|
||||
if (!box) {
|
||||
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
|
||||
}
|
||||
return { text: text ?? "", height: box.height };
|
||||
}
|
||||
|
||||
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
|
||||
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
const samples: Sample[] = [];
|
||||
const deadline = Date.now() + durationMs;
|
||||
|
||||
while (Date.now() <= deadline || samples.length < 2) {
|
||||
samples.push(await sample(locator));
|
||||
await locator.page().waitForTimeout(intervalMs);
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function assertStableHeight(samples: Sample[], tolerance: number): void {
|
||||
const heights = samples.map((entry) => entry.height);
|
||||
const minHeight = Math.min(...heights);
|
||||
const maxHeight = Math.max(...heights);
|
||||
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
|
||||
tolerance,
|
||||
);
|
||||
}
|
||||
|
||||
function assertSettledText(samples: Sample[]): void {
|
||||
let stableText: string | null = null;
|
||||
let previousText: string | null = null;
|
||||
|
||||
for (const entry of samples) {
|
||||
if (stableText !== null && entry.text !== stableText) {
|
||||
throw new Error(
|
||||
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (previousText === entry.text) {
|
||||
stableText = entry.text;
|
||||
}
|
||||
previousText = entry.text;
|
||||
}
|
||||
|
||||
if (stableText === null) {
|
||||
throw new Error("Text did not reach a stable value during the settledness window.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
|
||||
const samples = await collectSamples(locator, options);
|
||||
assertSettledText(samples);
|
||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||
}
|
||||
|
||||
export async function expectStableHeight(
|
||||
locator: Locator,
|
||||
options?: SettledOptions,
|
||||
): Promise<void> {
|
||||
const samples = await collectSamples(locator, options);
|
||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||
}
|
||||
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
@@ -62,13 +61,14 @@ export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("sidebar-close");
|
||||
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
|
||||
await closeButton.click({ force: true });
|
||||
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
// The mobile sidebar panel animates via translateX. Waiting for its header to be fully visible
|
||||
// prevents a close click from targeting a button while the panel is still moving.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,17 @@ export interface SeededSubagentPair {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SeededCrossWorkspaceSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
child: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedParentWithSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
@@ -51,6 +62,60 @@ export async function seedParentWithSubagent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedParentWithCrossWorkspaceSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
): Promise<SeededCrossWorkspaceSubagentPair> {
|
||||
const parent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.parentTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const createdWorkspace = await workspace.client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: workspace.repoPath,
|
||||
projectId: workspace.projectId,
|
||||
},
|
||||
title: "Subagent workspace",
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? "Failed to create subagent workspace");
|
||||
}
|
||||
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "stay running",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
child.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
}
|
||||
|
||||
@@ -78,13 +78,8 @@ export async function navigateToTerminal(
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page
|
||||
.getByText("localhost", { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||
// Its presence is the user-visible proof that workspace and terminal state have hydrated.
|
||||
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
|
||||
// so we give it enough time for the full workspace hydration + tab creation cycle.
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
@@ -91,6 +92,10 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||
await modeControl.click();
|
||||
|
||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoTruncation(popup);
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
@@ -105,6 +110,19 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
||||
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
|
||||
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
|
||||
await thinkingControl.click();
|
||||
|
||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoTruncation(popup);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
@@ -158,6 +176,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectThinkingOptionsFit(page);
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||
|
||||
|
||||
88
packages/app/e2e/new-workspace-composer-draft.spec.ts
Normal file
88
packages/app/e2e/new-workspace-composer-draft.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
selectNewWorkspaceHost,
|
||||
selectNewWorkspaceProject,
|
||||
} from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const DRAFT = `Please investigate the workspace startup failure.
|
||||
|
||||
Trace the request from the app through the daemon, preserve the existing behavior, and explain the root cause before making changes.`;
|
||||
|
||||
test.describe("New workspace composer draft", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps the draft when the project changes", async ({ page }) => {
|
||||
const firstProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-a-",
|
||||
});
|
||||
const secondProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-b-",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectId,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectId,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await firstProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the draft when the host changes", async ({ page }) => {
|
||||
const project: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-host-",
|
||||
});
|
||||
const secondaryServerId = "new-workspace-draft-secondary-host";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: secondaryServerId,
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
@@ -63,7 +63,11 @@ test.describe("New workspace isolation memory", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
hasGithubAuth,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { chooseAddProjectMethod, expectAddProjectPage } from "./helpers/add-project-flow";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
@@ -209,6 +212,54 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
const emptyServerId = "empty-new-workspace-host";
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: emptyServerId,
|
||||
label: "Empty host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
const projectTrigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await projectTrigger.click();
|
||||
await page.getByPlaceholder("Search projects").fill("no matching project");
|
||||
await expect(page.getByTestId("new-workspace-project-picker-add-project")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByTestId(`new-workspace-host-picker-option-${emptyServerId}`).click();
|
||||
await expect(projectTrigger).toContainText("Choose project");
|
||||
await projectTrigger.click();
|
||||
|
||||
const addProject = page.getByTestId("new-workspace-project-picker-add-project");
|
||||
await expect(addProject).toContainText("Add project");
|
||||
await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/);
|
||||
await addProject.click();
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
71
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
71
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
||||
|
||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: projectPickerFixture.projectName,
|
||||
projectPath: projectPickerFixture.projectPath,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("canceling Browse returns to the Add Project methods", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const dialogOptions = await waitForDirectoryDialog(page);
|
||||
expect(dialogOptions).toEqual({
|
||||
createDirectory: true,
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
await expect(browse).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectPickerFixture.projectName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addProjectFlowInput,
|
||||
chooseAddProjectMethod,
|
||||
openAddProjectFlow,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
@@ -134,10 +139,10 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
}
|
||||
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
const input = addProjectFlowInput(page);
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
|
||||
96
packages/app/e2e/provider-removal.spec.ts
Normal file
96
packages/app/e2e/provider-removal.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Dialog } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { connectDaemonClient } from "./helpers/daemon-client-loader";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function removeCustomProvider(client: ProviderRemovalDaemonClient): Promise<void> {
|
||||
await client.patchDaemonConfig({ removeProviders: [CUSTOM_PROVIDER.id] });
|
||||
}
|
||||
|
||||
async function expectProviderSource(
|
||||
client: ProviderRemovalDaemonClient,
|
||||
source: "custom" | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return snapshot.entries.find((entry) => entry.provider === CUSTOM_PROVIDER.id)?.source;
|
||||
})
|
||||
.toBe(source);
|
||||
}
|
||||
|
||||
async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog> {
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
expect(dialog.message()).toContain(`Remove ${CUSTOM_PROVIDER.name}?`);
|
||||
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
|
||||
if (!warning) {
|
||||
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("provider removal", () => {
|
||||
test("removes a custom provider from Settings", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderSource(client, "custom");
|
||||
|
||||
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
|
||||
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
|
||||
await clickRemoveProviderAndAcceptWarning(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: `${CUSTOM_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expectProviderSource(client, undefined);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
98
packages/app/e2e/provider-subagents.real.spec.ts
Normal file
98
packages/app/e2e/provider-subagents.real.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
import { openSubagentsTrack } from "./helpers/subagents";
|
||||
|
||||
interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
expectedName: string;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const cases: ProviderSubagentCase[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||
expectedName: "sentinel_child",
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
expectedName: "Sentinel child",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
prompt:
|
||||
'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
expectedName: "Explore",
|
||||
prompt:
|
||||
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider subagent timelines", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of cases) {
|
||||
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openSubagentsTrack(page);
|
||||
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||
await expect(rows.first()).toContainText(scenario.expectedName);
|
||||
await rows.first().click();
|
||||
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
const archiveFinished = page.getByTestId("subagents-track-archive-finished");
|
||||
await expect(archiveFinished).toBeVisible({ timeout: 30_000 });
|
||||
await archiveFinished.click();
|
||||
await expect(rows).toHaveCount(0, { timeout: 30_000 });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
FAKE_HOST_MODEL_ID,
|
||||
FAKE_HOST_MODEL_LABEL,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
@@ -56,6 +66,42 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
type FakeScheduleHostSchedule = NonNullable<
|
||||
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
|
||||
>[number];
|
||||
|
||||
function buildFakeHostSchedule(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}): FakeScheduleHostSchedule {
|
||||
const now = "2026-07-01T00:00:00.000Z";
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
prompt: "Run on the secondary host.",
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
model: FAKE_HOST_MODEL_ID,
|
||||
modeId: "load-test",
|
||||
title: input.name,
|
||||
},
|
||||
},
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nextRunAt: now,
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
@@ -79,14 +125,114 @@ test.describe("Schedules", () => {
|
||||
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,
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
const scheduleId = "fake-host-schedule";
|
||||
const scheduleName = "Secondary host schedule";
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
schedules: [
|
||||
buildFakeHostSchedule({
|
||||
id: scheduleId,
|
||||
name: scheduleName,
|
||||
cwd: String(fakeHost.workspace.workspaceDirectory),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
|
||||
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
});
|
||||
|
||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Pristine create ${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 row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,27 @@ import {
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
interface ScheduleListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
target: { type: string; config?: { cwd?: string } };
|
||||
cadence?: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: string;
|
||||
config?: {
|
||||
cwd?: string;
|
||||
archiveOnFinish?: boolean;
|
||||
isolation?: "local" | "worktree";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
@@ -24,7 +38,12 @@ 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();
|
||||
const searchInput = page.getByTestId("model-search-input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(label);
|
||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -40,6 +59,33 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
|
||||
async function expectScheduleCreatedForProject(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
cadenceExpression: 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,
|
||||
cadence: expect.objectContaining({
|
||||
type: "cron",
|
||||
expression: input.cadenceExpression,
|
||||
}),
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectScheduleKnobs(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
archiveOnFinish: boolean;
|
||||
isolation: "local" | "worktree";
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
@@ -50,13 +96,54 @@ async function expectScheduleCreatedForProject(input: {
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
archiveOnFinish: input.archiveOnFinish,
|
||||
isolation: input.isolation,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openNewScheduleSheet(page: Page): Promise<void> {
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
}
|
||||
|
||||
function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
|
||||
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
|
||||
const backdrop = bottomSheetBackdrop(page);
|
||||
await expect(backdrop).toBeVisible({ timeout: 10_000 });
|
||||
await expect(async () => {
|
||||
const box = await backdrop.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
||||
}
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
|
||||
}
|
||||
|
||||
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (!schedule) {
|
||||
throw new Error(`Expected schedule named ${name} to exist`);
|
||||
}
|
||||
return schedule.id;
|
||||
}
|
||||
|
||||
test.describe("Schedules project target", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
@@ -67,10 +154,30 @@ test.describe("Schedules project target", () => {
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openNewScheduleSheet(page);
|
||||
await dismissScheduleSheetWithBackdrop(page);
|
||||
await expectScheduleSheetClosedAndStable(page);
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openNewScheduleSheet(page);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expectScheduleSheetClosedAndStable(page);
|
||||
});
|
||||
|
||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
@@ -84,29 +191,84 @@ test.describe("Schedules project target", () => {
|
||||
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);
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).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,
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
||||
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||
await page.getByTestId("schedule-cadence-preset-daily-9").click();
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
|
||||
|
||||
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 });
|
||||
await expectScheduleCreatedForProject({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
cadenceExpression: "0 9 * * *",
|
||||
});
|
||||
|
||||
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 addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
|
||||
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
||||
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await hostFilterTrigger.click();
|
||||
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(hostFilterPopup);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears the selected model when the chosen project moves to another host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("clears the selected project and model when the host changes", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const serverId = getServerId();
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
@@ -128,26 +290,126 @@ test.describe("Schedules project target", () => {
|
||||
});
|
||||
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 });
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectTrigger).toHaveCount(0);
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await hostTrigger.click();
|
||||
await page.getByTestId(`schedule-host-option-${serverId}`).click();
|
||||
await expect(projectTrigger).toBeVisible();
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await expectSettled(hostTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
await expectSettled(modeTrigger);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await hostTrigger.click();
|
||||
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
||||
await expect(hostTrigger).toContainText("Fake host");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await expectSettled(hostTrigger);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
||||
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 expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(/select model/i);
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Knob schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("schedule-isolation-trigger").click();
|
||||
await page.getByTestId("schedule-isolation-worktree").click();
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
|
||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||
await page.getByTestId("schedule-cadence-preset-every-hour").click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleKnobs({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
archiveOnFinish: false,
|
||||
isolation: "worktree",
|
||||
});
|
||||
|
||||
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
|
||||
await page.getByTestId(`schedule-row-${scheduleId}`).click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("schedule-isolation-trigger").click();
|
||||
await page.getByTestId("schedule-isolation-local").click();
|
||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleKnobs({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
archiveOnFinish: true,
|
||||
isolation: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
seedSavedSettingsHosts,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -64,11 +65,54 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("a failed remote daemon update remains visible in the host UI", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openHostSection(page, outdatedDaemon.serverId, "host");
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
const updateButton = page.getByTestId("host-page-update-button");
|
||||
await updateButton.click();
|
||||
|
||||
await expect(
|
||||
updateButton.filter({ hasText: /Preparing update|Downloading packages|Installing/ }),
|
||||
).toBeDisabled();
|
||||
|
||||
const updateFailure = page.getByTestId("host-page-update-error");
|
||||
await expect(updateFailure).toBeVisible();
|
||||
await expect(updateFailure).toContainText("Update failed");
|
||||
await expect(updateFailure).toContainText("Failed to update the daemon:");
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a Desktop-managed daemon explains why its update action is disabled", async ({
|
||||
page,
|
||||
desktopManagedOutdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId);
|
||||
await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host");
|
||||
|
||||
const updateCard = page.getByTestId("host-page-update-card");
|
||||
await expect(updateCard).toBeVisible();
|
||||
await expect(updateCard).toContainText(
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
);
|
||||
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
105
packages/app/e2e/sidebar-help.spec.ts
Normal file
105
packages/app/e2e/sidebar-help.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { openSettingsSection } from "./helpers/settings";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
const GITHUB_ISSUE_DESTINATION =
|
||||
/^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/;
|
||||
const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/;
|
||||
const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
async function openHelpMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDiagnosticReport(page: Page): Promise<void> {
|
||||
const sheet = page.getByTestId("app-diagnostic-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByRole("button", { name: "Copy diagnostic" })).toBeEnabled();
|
||||
await expect(page.getByText(/App version:/).first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeSheet(page: Page, testID: string): Promise<void> {
|
||||
const sheet = page.getByTestId(testID);
|
||||
await sheet.getByLabel("Close").click();
|
||||
await expect(sheet).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExternalPage(
|
||||
page: Page,
|
||||
actionTestID: string,
|
||||
expectedUrl: RegExp,
|
||||
): Promise<void> {
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByTestId(actionTestID).click();
|
||||
const popup = await popupPromise;
|
||||
expect(popup.url()).toMatch(expectedUrl);
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
await openHelpMenu(page);
|
||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
});
|
||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
});
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
|
||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
await closeSheet(page, "app-diagnostic-sheet");
|
||||
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
test("opens support and release destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
|
||||
test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
|
||||
await page.getByRole("button", { name: "Run", exact: true }).click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
|
||||
test.describe("compact sidebar help", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expect(page.getByTestId("sidebar-help-shortcuts")).toHaveCount(0);
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
});
|
||||
@@ -165,3 +165,132 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Half-screen desktop layout", () => {
|
||||
test.use({ viewport: { width: 751, height: 982 } });
|
||||
|
||||
test("keeps the sidebar scroll position across close and reopen", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" });
|
||||
|
||||
try {
|
||||
let lastWorkspaceId = workspace.workspaceId;
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const created = await workspace.client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: workspace.repoPath,
|
||||
projectId: workspace.projectId,
|
||||
},
|
||||
title: `Retained sidebar ${index + 1}`,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to fill the retained sidebar");
|
||||
}
|
||||
lastWorkspaceId = created.workspace.id;
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const scrollTop = await sidebarScroll.evaluate((element) => {
|
||||
element.scrollTop = 160;
|
||||
return element.scrollTop;
|
||||
});
|
||||
expect(scrollTop).toBe(160);
|
||||
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openBounds = await openToggle.locator("svg").first().boundingBox();
|
||||
expect(openBounds).not.toBeNull();
|
||||
expect(openBounds?.x).toBeGreaterThan(12);
|
||||
|
||||
await openToggle.click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
const closedToggle = page.getByTestId("menu-button");
|
||||
const closedBounds = await closedToggle.locator("svg").first().boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
});
|
||||
|
||||
test("yields app navigation to the settings split", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("yields app navigation to the Explorer", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-close")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox();
|
||||
const headerGlyphBounds = await page
|
||||
.getByTestId("menu-button")
|
||||
.locator("svg")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const tabGlyphBounds = await page
|
||||
.locator('[data-testid^="workspace-tab-"]')
|
||||
.first()
|
||||
.locator("svg")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(centerBounds).not.toBeNull();
|
||||
expect(headerGlyphBounds).not.toBeNull();
|
||||
expect(tabGlyphBounds).not.toBeNull();
|
||||
expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo(
|
||||
(tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
|
||||
)
|
||||
.toBeGreaterThanOrEqual(400);
|
||||
|
||||
await page.getByTestId("explorer-close").click();
|
||||
await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
26
packages/app/e2e/workspace-archive-shortcut.spec.ts
Normal file
26
packages/app/e2e/workspace-archive-shortcut.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import {
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithCrossWorkspaceSubagent,
|
||||
} from "./helpers/subagents";
|
||||
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
@@ -527,4 +533,39 @@ test.describe("Workspace model regressions", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-workspace subagent opens in its workspace and keeps its parent relationship", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "workspace-cross-subagent-" });
|
||||
|
||||
try {
|
||||
const agents = await seedParentWithCrossWorkspaceSubagent(seeded, {
|
||||
parentTitle: "Parent agent",
|
||||
childTitle: "Cross-workspace subagent",
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, agents.child.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceTabVisible(page, agents.child.id);
|
||||
|
||||
const parentRowTestId = `sidebar-workspace-row-${serverId}:${agents.parent.workspaceId}`;
|
||||
const childRowTestId = `sidebar-workspace-row-${serverId}:${agents.child.workspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: childRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: parentRowTestId,
|
||||
indicator: "done",
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, agents.parent.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,10 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
@@ -55,7 +56,9 @@ async function expectEditorOpened(input: {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some((record) => record.editorId === input.editorId && record.path === input.path);
|
||||
.some(
|
||||
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -75,8 +78,18 @@ test.describe("Workspace open in editor", () => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{ id: "cursor", label: "Cursor", kind: "editor" },
|
||||
{ id: "vscode", label: "VS Code", kind: "editor" },
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree archive", () => {
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { rename } from "node:fs/promises";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
expectWorkspaceBranch,
|
||||
openChangesPanel,
|
||||
switchBranchFromChangesPanel,
|
||||
} from "./helpers/branch-switcher";
|
||||
import {
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowNotArchived,
|
||||
fetchAgentArchivedAt,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
@@ -35,6 +44,63 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
const seeded = await createArchivedMissingWorktree(prefix);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowNotArchived(page, seeded.agent.title);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click();
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) {
|
||||
const seeded = await createArchivedMissingWorktree(prefix);
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId);
|
||||
const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`);
|
||||
|
||||
await page.goto(`${workspaceRoute}?open=${openAgent}`);
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
@@ -49,7 +115,7 @@ test.describe("Worktree restore", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({
|
||||
test("opening an active History agent navigates without restoring or unarchiving", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -69,70 +135,176 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await archiveAgentFromDaemon(client, agent.id);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// The History list is a cached react-query snapshot, so the cleared Archived
|
||||
// badge only renders after a cold refetch. Reload to remount the query fresh.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
const row = page
|
||||
.locator('[data-testid^="agent-row-"]')
|
||||
.filter({ hasText: agent.title })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`),
|
||||
).toHaveCount(1);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({
|
||||
test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready");
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
await expect(
|
||||
worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId),
|
||||
).resolves.toMatchObject({ kind: "recoverable", action: "restore" });
|
||||
});
|
||||
|
||||
test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration(
|
||||
page,
|
||||
"restore-success",
|
||||
);
|
||||
await worktreeClient.fetchWorkspaces({
|
||||
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
|
||||
});
|
||||
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribeSecondaryWorkspaceUpdate = () => {};
|
||||
const secondaryWorkspaceUpdate = new Promise<void>((resolve, reject) => {
|
||||
updateTimeout = setTimeout(
|
||||
() => reject(new Error("Secondary client did not receive the restored workspace")),
|
||||
30_000,
|
||||
);
|
||||
unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => {
|
||||
if (
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.workspace.id === worktree.workspaceId
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
|
||||
await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await secondaryWorkspaceUpdate;
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
} finally {
|
||||
unsubscribeSecondaryWorkspaceUpdate();
|
||||
if (updateTimeout) {
|
||||
clearTimeout(updateTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`;
|
||||
execFileSync("git", ["branch", switchedBranch], {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
stdio: "pipe",
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, worktree.workspaceName);
|
||||
await switchBranchFromChangesPanel(page, {
|
||||
from: worktree.workspaceName,
|
||||
to: switchedBranch,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
await expectWorkspaceBranch(page, switchedBranch);
|
||||
await expect(
|
||||
page.getByTestId("workspace-header-title").filter({ visible: true }).first(),
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Archive through the default production path the sidebar uses (no explicit
|
||||
// scope). With the restore prune fix, this default path frees the kept branch
|
||||
// so the daemon can re-check-out the worktree on restore.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
await rename(tempRepo.path, displacedProjectPath);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
} finally {
|
||||
await rename(displacedProjectPath, tempRepo.path);
|
||||
}
|
||||
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an unrecoverable missing workspace shows no misleading recovery action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: project.workspaceDirectory,
|
||||
workspaceId: project.workspaceId,
|
||||
title: `unrecoverable-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId);
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
|
||||
const displacedProjectPath = `${tempRepo.path}-missing`;
|
||||
await rename(tempRepo.path, displacedProjectPath);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The archived workspace directory no longer exists and cannot be recreated.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
} finally {
|
||||
await rename(displacedProjectPath, tempRepo.path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0",
|
||||
"appVersionSource": "remote"
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -19,9 +19,6 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": "versionCode"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -7,6 +7,11 @@ const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
"expo-notifications": path.resolve(appSrcRoot, "fdroid/expo-notifications.ts"),
|
||||
};
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.trim()
|
||||
.replace(/^\./, "")
|
||||
@@ -14,6 +19,12 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
|
||||
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
||||
// crawler behavior depends on the host Watchman build/capabilities, while the
|
||||
// node crawler is the path used when Watchman is absent.
|
||||
config.resolver.useWatchman = false;
|
||||
|
||||
const escapedAppSrcRoot = appSrcRoot
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
@@ -66,6 +77,10 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
}
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
|
||||
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
|
||||
}
|
||||
|
||||
const origin = context.originModulePath;
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,6 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
@@ -127,6 +128,7 @@
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
|
||||
85
packages/app/plugins/with-fdroid-autolinking.js
Normal file
85
packages/app/plugins/with-fdroid-autolinking.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||
|
||||
const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-camera",
|
||||
"expo-notifications",
|
||||
"expo-dev-client",
|
||||
"expo-dev-launcher",
|
||||
"expo-dev-menu",
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
function withFdroidAutolinking(config) {
|
||||
config = withDangerousMod(config, [
|
||||
"android",
|
||||
async (modConfig) => {
|
||||
const packageJsonPath = path.join(modConfig.modRequest.projectRoot, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
const expo = packageJson.expo ?? {};
|
||||
const autolinking = expo.autolinking ?? {};
|
||||
const android = autolinking.android ?? {};
|
||||
const fdroidPackageJson = {
|
||||
...packageJson,
|
||||
expo: {
|
||||
...expo,
|
||||
autolinking: {
|
||||
...autolinking,
|
||||
android: {
|
||||
...android,
|
||||
buildFromSource: [".*"],
|
||||
exclude: EXCLUDED_ANDROID_MODULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const overlayRoot = path.join(modConfig.modRequest.platformProjectRoot, "fdroid-autolinking");
|
||||
|
||||
await fs.mkdir(overlayRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(overlayRoot, "package.json"),
|
||||
`${JSON.stringify(fdroidPackageJson, null, 2)}\n`,
|
||||
);
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
|
||||
config = withSettingsGradle(config, (modConfig) => {
|
||||
const fdroidProjectRoot =
|
||||
'expoAutolinking.projectRoot = new File(rootDir, "fdroid-autolinking")';
|
||||
if (modConfig.modResults.contents.includes(fdroidProjectRoot)) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const useExpoModules = "expoAutolinking.useExpoModules()";
|
||||
if (!modConfig.modResults.contents.includes(useExpoModules)) {
|
||||
throw new Error("Could not configure F-Droid Expo autolinking in settings.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
useExpoModules,
|
||||
`${fdroidProjectRoot}\n${useExpoModules}`,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
|
||||
return withAppBuildGradle(config, (modConfig) => {
|
||||
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const androidBlock = "android {";
|
||||
if (!modConfig.modResults.contents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withFdroidAutolinking;
|
||||
200
packages/app/src/add-project-flow/model.test.ts
Normal file
200
packages/app/src/add-project-flow/model.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
backAddProjectPage,
|
||||
chooseAddProjectHost,
|
||||
currentAddProjectPage,
|
||||
moveAddProjectActiveIndex,
|
||||
moveAddProjectSelection,
|
||||
openAddProjectFlow,
|
||||
openDirectorySearchPage,
|
||||
openGithubLocationPage,
|
||||
openNewDirectoryNamePage,
|
||||
openNewDirectoryParentPage,
|
||||
setAddProjectActiveIndex,
|
||||
setAddProjectPageInput,
|
||||
setNewDirectoryName,
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
} from "./options";
|
||||
|
||||
const HOST: AddProjectHost = {
|
||||
serverId: "host-1",
|
||||
label: "Local",
|
||||
canAddProject: true,
|
||||
canBrowse: true,
|
||||
canCloneGithubRepositories: true,
|
||||
canSearchGithubRepositories: true,
|
||||
canCreateDirectory: true,
|
||||
};
|
||||
|
||||
describe("Add Project navigation", () => {
|
||||
it("skips a single connected host without adding it to history", () => {
|
||||
const state = openAddProjectFlow({ hosts: [HOST] });
|
||||
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "method",
|
||||
hostId: "host-1",
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
});
|
||||
expect(backAddProjectPage(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("restores page input and selection after Back", () => {
|
||||
const secondHost = { ...HOST, serverId: "host-2", label: "Remote" };
|
||||
let state = openAddProjectFlow({ hosts: [HOST, secondHost] });
|
||||
state = setAddProjectPageInput(state, "rem");
|
||||
state = setAddProjectActiveIndex(state, 1);
|
||||
state = chooseAddProjectHost(state, secondHost.serverId);
|
||||
state = openDirectorySearchPage(state, secondHost.serverId);
|
||||
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "host",
|
||||
query: "rem",
|
||||
activeIndex: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps keyboard selection in both directions", () => {
|
||||
expect(moveAddProjectActiveIndex(2, 3, "next")).toBe(0);
|
||||
expect(moveAddProjectActiveIndex(0, 3, "previous")).toBe(2);
|
||||
expect(moveAddProjectSelection(0, [true, false, true], "next")).toBe(2);
|
||||
});
|
||||
|
||||
it("restores a directory name after returning to and reselecting its parent", () => {
|
||||
let state = openAddProjectFlow({ hosts: [HOST] });
|
||||
state = openNewDirectoryParentPage(state, HOST.serverId);
|
||||
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
|
||||
state = setNewDirectoryName(state, "command-center");
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
|
||||
|
||||
expect(currentAddProjectPage(state)).toMatchObject({
|
||||
kind: "new-directory-name",
|
||||
parentPath: "~/dev",
|
||||
name: "command-center",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the GitHub destination query and active parent when reopening a repository", () => {
|
||||
const repository = {
|
||||
id: "repo-1",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
description: null,
|
||||
visibility: "public",
|
||||
updatedAt: null,
|
||||
};
|
||||
let state = openAddProjectFlow({ hosts: [HOST] });
|
||||
state = openGithubLocationPage(state, HOST.serverId, repository);
|
||||
state = setAddProjectPageInput(state, "~/dev");
|
||||
state = setAddProjectActiveIndex(state, 2);
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = openGithubLocationPage(state, HOST.serverId, repository);
|
||||
|
||||
expect(currentAddProjectPage(state)).toMatchObject({
|
||||
kind: "github-location",
|
||||
query: "~/dev",
|
||||
activeIndex: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
...HOST,
|
||||
canBrowse: false,
|
||||
canCloneGithubRepositories: false,
|
||||
canSearchGithubRepositories: false,
|
||||
canCreateDirectory: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: "Find a directory on Local",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "Clone from GitHub",
|
||||
description: "Update this host to clone GitHub repositories",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: "new-directory",
|
||||
label: "New directory",
|
||||
description: "Update this host to create directories",
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers manual URL and protocol-specific owner/repo clone choices", () => {
|
||||
expect(buildManualGithubRepositoryChoices("git@github.com:getpaseo/paseo.git")).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "manual:git@github.com:getpaseo/paseo.git",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
}),
|
||||
]);
|
||||
expect(buildManualGithubRepositoryChoices("getpaseo/paseo")).toEqual([
|
||||
expect.objectContaining({ cloneProtocol: "https", cloneUrl: "getpaseo/paseo" }),
|
||||
expect.objectContaining({ cloneProtocol: "ssh", cloneUrl: "getpaseo/paseo" }),
|
||||
]);
|
||||
expect(buildManualGithubRepositoryChoices("paseo")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows final clone paths while retaining parent paths as values", () => {
|
||||
expect(
|
||||
buildCloneLocationOptions({
|
||||
parents: ["~/dev", "~/workspace"],
|
||||
repositoryName: "paseo",
|
||||
existingPaths: ["~/workspace/paseo"],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "~/dev",
|
||||
path: "~/dev",
|
||||
displayPath: "~/dev/paseo",
|
||||
secondaryText: "Parent directory: ~/dev",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "~/workspace",
|
||||
path: "~/workspace",
|
||||
displayPath: "~/workspace/paseo",
|
||||
secondaryText: "Already exists",
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows equivalent absolute-home and tilde destinations only once", () => {
|
||||
expect(
|
||||
buildCloneLocationOptions({
|
||||
parents: ["/Users/moboudra/dev", "~/dev"],
|
||||
repositoryName: "dotfiles",
|
||||
existingPaths: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "/Users/moboudra/dev",
|
||||
path: "/Users/moboudra/dev",
|
||||
displayPath: "/Users/moboudra/dev/dotfiles",
|
||||
secondaryText: "Parent directory: /Users/moboudra/dev",
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
293
packages/app/src/add-project-flow/model.ts
Normal file
293
packages/app/src/add-project-flow/model.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
export interface AddProjectHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
canAddProject: boolean;
|
||||
canBrowse: boolean;
|
||||
canCloneGithubRepositories: boolean;
|
||||
canSearchGithubRepositories: boolean;
|
||||
canCreateDirectory: boolean;
|
||||
}
|
||||
|
||||
export interface GithubRepositoryChoice {
|
||||
id: string;
|
||||
nameWithOwner: string;
|
||||
cloneUrl: string;
|
||||
cloneProtocol?: "https" | "ssh";
|
||||
description: string | null;
|
||||
visibility: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState {
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type AddProjectPage =
|
||||
| ({ kind: "host" } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string } & SearchPageState)
|
||||
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
|
||||
| ({ kind: "github-search"; hostId: string } & SearchPageState)
|
||||
| ({
|
||||
kind: "github-location";
|
||||
hostId: string;
|
||||
repository: GithubRepositoryChoice;
|
||||
isSubmitting: boolean;
|
||||
} & SearchPageState)
|
||||
| ({ kind: "new-directory-parent"; hostId: string } & SearchPageState)
|
||||
| {
|
||||
kind: "new-directory-name";
|
||||
hostId: string;
|
||||
parentPath: string;
|
||||
name: string;
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export interface AddProjectFlowState {
|
||||
hosts: AddProjectHost[];
|
||||
pages: AddProjectPage[];
|
||||
newDirectoryNameDrafts: Record<string, string>;
|
||||
githubLocationDrafts: Record<string, { query: string; activeIndex: number }>;
|
||||
}
|
||||
|
||||
export interface OpenAddProjectFlowInput {
|
||||
hosts: AddProjectHost[];
|
||||
preferredHostId?: string;
|
||||
}
|
||||
|
||||
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
|
||||
return { kind, query: "", activeIndex: 0, error: null } as const;
|
||||
}
|
||||
|
||||
function methodPage(hostId: string): AddProjectPage {
|
||||
return { ...searchPage("method"), hostId };
|
||||
}
|
||||
|
||||
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
|
||||
const preferredHost = input.preferredHostId
|
||||
? input.hosts.find((host) => host.serverId === input.preferredHostId)
|
||||
: null;
|
||||
const onlyHost = input.hosts.length === 1 ? input.hosts[0] : null;
|
||||
const initialHost = preferredHost ?? onlyHost;
|
||||
|
||||
return {
|
||||
hosts: input.hosts,
|
||||
pages: initialHost ? [methodPage(initialHost.serverId)] : [searchPage("host")],
|
||||
newDirectoryNameDrafts: {},
|
||||
githubLocationDrafts: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAvailableAddProjectHosts(
|
||||
state: AddProjectFlowState,
|
||||
hosts: AddProjectHost[],
|
||||
preferredHostId?: string,
|
||||
): AddProjectFlowState {
|
||||
const current = currentAddProjectPage(state);
|
||||
if (state.pages.length !== 1 || current.kind !== "host") {
|
||||
return { ...state, hosts };
|
||||
}
|
||||
const preferredHost = preferredHostId
|
||||
? hosts.find((host) => host.serverId === preferredHostId)
|
||||
: null;
|
||||
const onlyHost = hosts.length === 1 ? hosts[0] : null;
|
||||
const initialHost = preferredHost ?? onlyHost;
|
||||
return {
|
||||
...state,
|
||||
hosts,
|
||||
pages: initialHost ? [methodPage(initialHost.serverId)] : state.pages,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentAddProjectPage(state: AddProjectFlowState): AddProjectPage {
|
||||
const page = state.pages[state.pages.length - 1];
|
||||
if (!page) {
|
||||
throw new Error("Add Project flow must always contain a page");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
export function updateCurrentAddProjectPage(
|
||||
state: AddProjectFlowState,
|
||||
update: (page: AddProjectPage) => AddProjectPage,
|
||||
): AddProjectFlowState {
|
||||
const index = state.pages.length - 1;
|
||||
return {
|
||||
...state,
|
||||
pages: state.pages.map((page, pageIndex) => (pageIndex === index ? update(page) : page)),
|
||||
};
|
||||
}
|
||||
|
||||
export function pushAddProjectPage(
|
||||
state: AddProjectFlowState,
|
||||
page: AddProjectPage,
|
||||
): AddProjectFlowState {
|
||||
return { ...state, pages: [...state.pages, page] };
|
||||
}
|
||||
|
||||
export function backAddProjectPage(state: AddProjectFlowState): AddProjectFlowState | null {
|
||||
if (state.pages.length === 1) {
|
||||
return null;
|
||||
}
|
||||
return { ...state, pages: state.pages.slice(0, -1) };
|
||||
}
|
||||
|
||||
export function chooseAddProjectHost(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
): AddProjectFlowState {
|
||||
return pushAddProjectPage(state, methodPage(hostId));
|
||||
}
|
||||
|
||||
export function openDirectorySearchPage(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
): AddProjectFlowState {
|
||||
return pushAddProjectPage(state, {
|
||||
...searchPage("directory-search"),
|
||||
hostId,
|
||||
isSubmitting: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function openGithubSearchPage(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
): AddProjectFlowState {
|
||||
return pushAddProjectPage(state, { ...searchPage("github-search"), hostId });
|
||||
}
|
||||
|
||||
export function openGithubLocationPage(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
repository: GithubRepositoryChoice,
|
||||
): AddProjectFlowState {
|
||||
const draft = state.githubLocationDrafts[githubLocationDraftKey(hostId, repository.id)];
|
||||
return pushAddProjectPage(state, {
|
||||
kind: "github-location",
|
||||
query: draft?.query ?? "",
|
||||
activeIndex: draft?.activeIndex ?? 0,
|
||||
error: null,
|
||||
hostId,
|
||||
repository,
|
||||
isSubmitting: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function openNewDirectoryParentPage(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
): AddProjectFlowState {
|
||||
return pushAddProjectPage(state, { ...searchPage("new-directory-parent"), hostId });
|
||||
}
|
||||
|
||||
export function openNewDirectoryNamePage(
|
||||
state: AddProjectFlowState,
|
||||
hostId: string,
|
||||
parentPath: string,
|
||||
): AddProjectFlowState {
|
||||
const draftKey = newDirectoryDraftKey(hostId, parentPath);
|
||||
return pushAddProjectPage(state, {
|
||||
kind: "new-directory-name",
|
||||
hostId,
|
||||
parentPath,
|
||||
name: state.newDirectoryNameDrafts[draftKey] ?? "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
isSubmitting: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAddProjectPageInput(
|
||||
state: AddProjectFlowState,
|
||||
value: string,
|
||||
): AddProjectFlowState {
|
||||
const page = currentAddProjectPage(state);
|
||||
const updated = updateCurrentAddProjectPage(state, (current) => {
|
||||
if (current.kind === "new-directory-name") {
|
||||
return { ...current, name: value, activeIndex: 0, error: null };
|
||||
}
|
||||
return { ...current, query: value, activeIndex: 0, error: null };
|
||||
});
|
||||
if (page.kind !== "github-location") return updated;
|
||||
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
|
||||
return {
|
||||
...updated,
|
||||
githubLocationDrafts: {
|
||||
...updated.githubLocationDrafts,
|
||||
[draftKey]: { query: value, activeIndex: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setNewDirectoryName(
|
||||
state: AddProjectFlowState,
|
||||
value: string,
|
||||
): AddProjectFlowState {
|
||||
const page = currentAddProjectPage(state);
|
||||
if (page.kind !== "new-directory-name") return state;
|
||||
const draftKey = newDirectoryDraftKey(page.hostId, page.parentPath);
|
||||
const updated = setAddProjectPageInput(state, value);
|
||||
return {
|
||||
...updated,
|
||||
newDirectoryNameDrafts: {
|
||||
...updated.newDirectoryNameDrafts,
|
||||
[draftKey]: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function newDirectoryDraftKey(hostId: string, parentPath: string): string {
|
||||
return `${hostId}\u0000${parentPath}`;
|
||||
}
|
||||
|
||||
function githubLocationDraftKey(hostId: string, repositoryId: string): string {
|
||||
return `${hostId}\u0000${repositoryId}`;
|
||||
}
|
||||
|
||||
export function setAddProjectActiveIndex(
|
||||
state: AddProjectFlowState,
|
||||
activeIndex: number,
|
||||
): AddProjectFlowState {
|
||||
const page = currentAddProjectPage(state);
|
||||
const updated = updateCurrentAddProjectPage(state, (current) => ({ ...current, activeIndex }));
|
||||
if (page.kind !== "github-location") return updated;
|
||||
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
|
||||
return {
|
||||
...updated,
|
||||
githubLocationDrafts: {
|
||||
...updated.githubLocationDrafts,
|
||||
[draftKey]: { query: page.query, activeIndex },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAddProjectActiveIndex(
|
||||
activeIndex: number,
|
||||
optionCount: number,
|
||||
direction: "next" | "previous",
|
||||
): number {
|
||||
if (optionCount === 0) return 0;
|
||||
const delta = direction === "next" ? 1 : -1;
|
||||
const next = activeIndex + delta;
|
||||
if (next < 0) return optionCount - 1;
|
||||
if (next >= optionCount) return 0;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function moveAddProjectSelection(
|
||||
activeIndex: number,
|
||||
selectable: readonly boolean[],
|
||||
direction: "next" | "previous",
|
||||
): number {
|
||||
if (!selectable.some(Boolean)) return 0;
|
||||
let next = activeIndex;
|
||||
for (let count = 0; count < selectable.length; count += 1) {
|
||||
next = moveAddProjectActiveIndex(next, selectable.length, direction);
|
||||
if (selectable[next]) return next;
|
||||
}
|
||||
return activeIndex;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user