mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
222 Commits
ci/build-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb8506d1f | ||
|
|
ab24070075 | ||
|
|
fab975a059 | ||
|
|
504b687f89 | ||
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 | ||
|
|
76e336a1be | ||
|
|
f0d7eeb98c | ||
|
|
f91a984348 | ||
|
|
cbbf6c1684 | ||
|
|
89c2fac3c6 | ||
|
|
f8dd0fc2e0 | ||
|
|
b55acfc60f | ||
|
|
a6fdcb469c | ||
|
|
5bd317205c | ||
|
|
e59c94812b | ||
|
|
1c8fabd293 | ||
|
|
717f195f0c | ||
|
|
869edcbf11 | ||
|
|
c596e058cd | ||
|
|
fa1198c2be | ||
|
|
1f253d92e2 | ||
|
|
43cf858c37 | ||
|
|
5d397754bd | ||
|
|
2acb10fce9 | ||
|
|
e1b1ca569d | ||
|
|
b97d6d13f3 | ||
|
|
80c8a08393 | ||
|
|
1d1132de9c | ||
|
|
1a1ff8828f | ||
|
|
bb6231d556 | ||
|
|
07aa48cd6e | ||
|
|
392095c1b2 | ||
|
|
fe28850fad | ||
|
|
72c7d3fe3e | ||
|
|
e859d2df12 | ||
|
|
c9fb31f709 | ||
|
|
51ab86bab6 | ||
|
|
65633004b2 | ||
|
|
8409e237ed | ||
|
|
be52347d67 | ||
|
|
457679d45a | ||
|
|
830c9b62c4 | ||
|
|
36f38245ca | ||
|
|
782b341b1a | ||
|
|
7ef3376b62 | ||
|
|
bb3f5c5a2d | ||
|
|
a5942ef2de | ||
|
|
73e290bb57 | ||
|
|
e22c85373c | ||
|
|
cf36b2cc40 | ||
|
|
99440201bd | ||
|
|
19565f6605 | ||
|
|
055db7454d | ||
|
|
7250009ab7 | ||
|
|
21404fbdec | ||
|
|
21597bdc1b | ||
|
|
967edab497 | ||
|
|
b218267c3c | ||
|
|
5170833961 | ||
|
|
f4136c6d3e | ||
|
|
d98c5e77f7 | ||
|
|
fbbdbdc571 | ||
|
|
7e97ab4a9c | ||
|
|
0cb9ecf44b | ||
|
|
afcd972dd0 | ||
|
|
48b14d27a5 | ||
|
|
609f81bc11 | ||
|
|
779a56ed36 | ||
|
|
7bd4afe848 | ||
|
|
fc10c79e26 | ||
|
|
08c522c986 | ||
|
|
4b5551d61c | ||
|
|
b02acb882c | ||
|
|
13bce05630 | ||
|
|
17c12e2e1a | ||
|
|
c469ac124a | ||
|
|
09cfdecbbf | ||
|
|
1c95f8c37e | ||
|
|
12612f6646 | ||
|
|
a290f74705 | ||
|
|
b73592ccac | ||
|
|
8a8f2baf80 | ||
|
|
a3438f96f8 | ||
|
|
eb83e2bb45 | ||
|
|
2bffd6e71e | ||
|
|
1ffa2f821a | ||
|
|
fd0deea1c1 | ||
|
|
e8fb9bda6c | ||
|
|
5e47cff58e | ||
|
|
8e063f0dfc | ||
|
|
8cf70d10bf | ||
|
|
31c8dc3f05 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 | ||
|
|
89a022e853 | ||
|
|
14b25d4266 | ||
|
|
6cddc657cd | ||
|
|
14acb97662 | ||
|
|
4a4556f499 | ||
|
|
21d9bdc644 | ||
|
|
97cfcf8306 | ||
|
|
9603397aed | ||
|
|
309672c8e5 | ||
|
|
2ed1f4f353 | ||
|
|
cb6d2f1459 | ||
|
|
cf9dfcf947 | ||
|
|
7d10791bad | ||
|
|
2cc1886a6b | ||
|
|
d27c7ad590 | ||
|
|
a4d11cda2b | ||
|
|
c049e86fee | ||
|
|
5c93ac4aa5 | ||
|
|
9ca790df6a | ||
|
|
ee431bb340 | ||
|
|
0707092131 | ||
|
|
b2139b1400 | ||
|
|
a8fb40e689 | ||
|
|
25cdda9492 | ||
|
|
d6dc309408 | ||
|
|
6755341fbb | ||
|
|
2cb1b041dd | ||
|
|
5605b2aa94 | ||
|
|
fd4e13735c | ||
|
|
6acc82e9d3 | ||
|
|
d0456b1943 | ||
|
|
8aa55db1e8 | ||
|
|
afeb4d18f8 | ||
|
|
f214261ae5 | ||
|
|
aa6384babd | ||
|
|
ddb6d97bf0 | ||
|
|
187561e433 | ||
|
|
045de763f7 | ||
|
|
1962816c1e | ||
|
|
8c92c7d423 | ||
|
|
345d8e5240 | ||
|
|
4bda2dfea9 | ||
|
|
9292f58896 | ||
|
|
2ead7e7719 | ||
|
|
b3b1283d3b | ||
|
|
e1bda8e498 | ||
|
|
07d988488e | ||
|
|
0c68b26a8b | ||
|
|
3d86c738ff | ||
|
|
c9bcfa7638 | ||
|
|
0cfb9b6b94 | ||
|
|
8cc2ae0ba4 | ||
|
|
99dc8ddda5 | ||
|
|
5ea311f243 | ||
|
|
2185779d6c | ||
|
|
e0e50c9a8e | ||
|
|
c0622a7046 | ||
|
|
45bbb973a3 | ||
|
|
7917532716 | ||
|
|
fcaa84f0e4 | ||
|
|
b4518cbf33 | ||
|
|
72752b7db6 | ||
|
|
05d1f838d8 | ||
|
|
c97f823236 | ||
|
|
ffe76a7e57 | ||
|
|
98f6611362 | ||
|
|
99da5736db | ||
|
|
d9abac0f8e | ||
|
|
745e8afe45 | ||
|
|
b4a5b6a3ff | ||
|
|
a1de743ef6 | ||
|
|
a414f8ea85 | ||
|
|
39cb3dbb9c | ||
|
|
1977d330ed | ||
|
|
6c99efae52 | ||
|
|
6f753a142d | ||
|
|
0bec06c2db | ||
|
|
c0f80e2477 | ||
|
|
293f55afc4 | ||
|
|
df2b7cab46 | ||
|
|
dfada2a556 | ||
|
|
263ccc2a19 | ||
|
|
388f1d426c | ||
|
|
a7cbf4f61d | ||
|
|
266d54463b | ||
|
|
557fc42c89 | ||
|
|
bce2c50b9e | ||
|
|
70472dc945 | ||
|
|
a8ebd390fa | ||
|
|
04d1ebdce0 | ||
|
|
9f5f5fce62 | ||
|
|
a622860a3e | ||
|
|
d2308f4835 | ||
|
|
d9a0b3e8d8 | ||
|
|
6db7e53b6e | ||
|
|
737f30c339 | ||
|
|
60855a0f3a | ||
|
|
623c05aa4d | ||
|
|
d5baf1a7e6 | ||
|
|
a1cd50c2ae | ||
|
|
7d80fdfd12 | ||
|
|
04c71c5890 | ||
|
|
721ef03779 | ||
|
|
ccf29f4c50 |
@@ -8,4 +8,6 @@ user-invocable: true
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.
|
||||
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: release-stable
|
||||
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
|
||||
description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
|
||||
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
|
||||
|
||||
291
.github/workflows/android-apk-release.yml
vendored
291
.github/workflows/android-apk-release.yml
vendored
@@ -7,229 +7,34 @@ 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: "Release tag to rebuild and publish. Required when publish is enabled."
|
||||
required: false
|
||||
default: ""
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }}
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
publish-android-apk:
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
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' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }}
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve build mode
|
||||
id: mode
|
||||
env:
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
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"
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
@@ -257,19 +62,71 @@ jobs:
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Download APK workflow artifact
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: ${{ needs.build-apk.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/apk
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs 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: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh release upload \
|
||||
"$RELEASE_TAG" \
|
||||
"$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \
|
||||
--clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
249
.github/workflows/ci.yml
vendored
249
.github/workflows/ci.yml
vendored
@@ -18,6 +18,106 @@ env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
|
||||
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
|
||||
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
|
||||
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
|
||||
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
|
||||
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
|
||||
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
|
||||
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
|
||||
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect affected CI jobs
|
||||
id: filter
|
||||
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
|
||||
with:
|
||||
filters: |
|
||||
shared:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
- '.mise.toml'
|
||||
- '.tool-versions'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- 'scripts/**'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.base.json'
|
||||
- 'vitest.config.ts'
|
||||
quality:
|
||||
- 'packages/**'
|
||||
- '*.cjs'
|
||||
- '*.js'
|
||||
- '*.json'
|
||||
- '*.mjs'
|
||||
- '*.ts'
|
||||
server:
|
||||
- 'packages/app/e2e/fixtures/recording.*'
|
||||
- 'packages/client/**'
|
||||
- 'packages/cli/src/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop:
|
||||
- 'packages/app/**'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop_package:
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'packages/desktop/**'
|
||||
app:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
sdk:
|
||||
- 'packages/client/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
playwright:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
relay:
|
||||
- 'packages/relay/**'
|
||||
cli:
|
||||
- 'nix/**'
|
||||
- 'packages/app/e2e/global-setup.ts'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/src/daemon/runtime-paths.ts'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
|
||||
- name: Validate CI workflow
|
||||
run: node --test scripts/ci-workflow.test.mjs
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -39,6 +139,12 @@ jobs:
|
||||
run: npx oxfmt --check .
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -63,6 +169,12 @@ jobs:
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -89,6 +201,8 @@ jobs:
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,28 +211,43 @@ jobs:
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected server tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No server changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -126,6 +255,8 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -134,38 +265,65 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Skip unaffected desktop tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No desktop changes detected."
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Run real Electron browser tab bridge E2E
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
|
||||
- name: Upload browser tab bridge diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
@@ -173,7 +331,11 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
@@ -182,6 +344,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -206,6 +374,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -232,6 +406,8 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -245,43 +421,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected Playwright tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No Playwright changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
if: env.RUN_TESTS == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && matrix.desktop
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: env.RUN_TESTS == 'true' && failure()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
@@ -290,6 +480,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -311,6 +507,8 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -319,21 +517,38 @@ jobs:
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected CLI tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No CLI changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
|
||||
18
.mise.toml
18
.mise.toml
@@ -1,10 +1,10 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
|
||||
_.path = [
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
|
||||
]
|
||||
[vars]
|
||||
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
|
||||
|
||||
[tools]
|
||||
java = "21"
|
||||
[env]
|
||||
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
|
||||
_.path = [
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
|
||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
|
||||
]
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
// React Native style arrays must read Unistyles proxies during render. Hoisting them to
|
||||
// satisfy this allocation rule captures the temporary startup theme instead.
|
||||
"react-perf/jsx-no-new-array-as-prop": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -1,7 +1,126 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.3 - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger))
|
||||
- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371))
|
||||
- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459))
|
||||
|
||||
### Improved
|
||||
|
||||
- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454))
|
||||
- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480))
|
||||
- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter))
|
||||
- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter))
|
||||
- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380))
|
||||
- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482))
|
||||
- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481))
|
||||
- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458))
|
||||
- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169))
|
||||
- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter))
|
||||
- `Shift+Tab` no longer changes a background agent’s permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter))
|
||||
- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter))
|
||||
- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476))
|
||||
- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457))
|
||||
|
||||
## 0.2.2 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude 5 models now use the correct context windows.
|
||||
|
||||
## 0.2.1 - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Opus 5 is available
|
||||
|
||||
## 0.2.0 - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
|
||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
|
||||
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
|
||||
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
|
||||
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
|
||||
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
|
||||
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
|
||||
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
|
||||
- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
|
||||
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
|
||||
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
|
||||
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
|
||||
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
|
||||
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
|
||||
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
|
||||
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
|
||||
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
|
||||
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
|
||||
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
|
||||
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
|
||||
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
|
||||
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
|
||||
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
|
||||
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
|
||||
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
|
||||
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
|
||||
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
|
||||
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
|
||||
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
|
||||
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
|
||||
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
|
||||
## 0.1.110 - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
|
||||
|
||||
## 0.1.109 - 2026-07-16
|
||||
|
||||
> **Important update notice**
|
||||
>
|
||||
> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.
|
||||
|
||||
### 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))
|
||||
|
||||
@@ -37,6 +37,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [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/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [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 |
|
||||
|
||||
17
README.ja.md
17
README.ja.md
@@ -151,23 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## コミュニティ
|
||||
## 関連プロジェクト
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
25
README.md
25
README.md
@@ -38,12 +38,6 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
|
||||
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
@@ -144,7 +138,7 @@ Quick monorepo package map:
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/relay`: Relay transport and encryption used by the daemon and clients
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
Common commands:
|
||||
@@ -166,23 +160,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Community
|
||||
## Related projects
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -151,9 +151,10 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 社区
|
||||
## 相关项目
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
@@ -202,18 +203,6 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
14
SECURITY.md
14
SECURITY.md
@@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
|
||||
|
||||
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
@@ -48,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
|
||||
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
|
||||
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
|
||||
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
|
||||
destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
@@ -68,6 +76,10 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr
|
||||
|
||||
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
|
||||
|
||||
## Forge host trust
|
||||
|
||||
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||
|
||||
@@ -10,7 +10,19 @@ initializing → idle → running → idle (or error → closed)
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Runtime residency
|
||||
|
||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
|
||||
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
|
||||
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -18,29 +30,31 @@ Cancellation changes lifecycle state only after the provider acknowledges the in
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||
|
||||
- `relationship` decides whether the new agent belongs under the caller.
|
||||
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
|
||||
|
||||
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
|
||||
|
||||
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
|
||||
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
## Provider-managed child agents
|
||||
|
||||
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
|
||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
||||
children.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
@@ -58,6 +72,20 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
|
||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
|
||||
received.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
@@ -133,11 +161,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
|
||||
|
||||
## Prerequisites (local dev)
|
||||
|
||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
|
||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
|
||||
|
||||
```bash
|
||||
mise install # java 21 + android-sdk 21.0 command-line tools
|
||||
```
|
||||
|
||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
|
||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
|
||||
|
||||
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
|
||||
|
||||
@@ -120,8 +120,20 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
|
||||
|
||||
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
|
||||
|
||||
For a single-ABI APK, pass React Native's architecture property to Gradle:
|
||||
|
||||
```bash
|
||||
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
|
||||
-PreactNativeArchitectures=arm64-v8a \
|
||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
|
||||
|
||||
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
|
||||
|
||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
@@ -137,20 +149,13 @@ 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 (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.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
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 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.
|
||||
`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.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -54,22 +54,30 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
for empty projects and without connected clients, then fans metadata changes through the WebSocket
|
||||
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -89,14 +97,22 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
The replica cache exists only to paint stale data immediately while the host connects. It does not
|
||||
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
|
||||
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
|
||||
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
|
||||
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
|
||||
written order; a single oversized host is omitted rather than partially restored.
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
@@ -105,27 +121,38 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo heartbeat create/update/delete`
|
||||
- `paseo workspace create/ls/archive`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||
- `paseo speech …`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
### `packages/relay` — Relay transport and E2E encryption
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
|
||||
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### Paseo Hub
|
||||
|
||||
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
|
||||
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
|
||||
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
@@ -138,9 +165,25 @@ 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 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 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 every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. 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 window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
|
||||
>
|
||||
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
|
||||
>
|
||||
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
|
||||
|
||||
> **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.
|
||||
```text
|
||||
Human key -> guest WebContents
|
||||
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
|
||||
`-- page keydown
|
||||
|-- page prevents ------> page owns it
|
||||
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
|
||||
|
||||
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|
||||
|-- guest handles ------------> page owns it
|
||||
`-- guest does not handle ----> stop; never redispatch to the host window
|
||||
```
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -169,11 +212,13 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
@@ -207,6 +252,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
|
||||
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
|
||||
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
|
||||
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
|
||||
messages or transfers.
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
@@ -255,14 +304,14 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
||||
|
||||
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
|
||||
|
||||
| Surface | Key | Source |
|
||||
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
| Surface | Key | Source |
|
||||
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
|
||||
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
|
||||
|
||||
@@ -338,5 +387,5 @@ $PASEO_HOME/
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -13,7 +13,16 @@ It validates the compositor behavior that unit tests cannot see:
|
||||
- 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.
|
||||
renderer coordination;
|
||||
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
|
||||
host composer;
|
||||
- the automation group loads the compiled production keyboard boundary and guest
|
||||
preload, then proves that initial page window handlers get first refusal, unhandled
|
||||
shortcuts synchronously suppress editable browser defaults before crossing the host
|
||||
boundary, shortcuts marked unavailable in editable targets retain the browser field's
|
||||
native behavior, handlers registered after preload still get first refusal, focused
|
||||
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
|
||||
stays in the guest.
|
||||
|
||||
Run it with the repo Electron:
|
||||
|
||||
@@ -21,9 +30,11 @@ Run it with the repo Electron:
|
||||
npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Run the browser automation fixture with:
|
||||
Build the desktop main process before the automation group so its production guest
|
||||
preload is available:
|
||||
|
||||
```bash
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
@@ -43,6 +54,9 @@ ARIA-like snapshot text includes headings, static text, and controls; refs survi
|
||||
`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.
|
||||
Keyboard containment runs last because the host-composer sentinel intentionally leaves
|
||||
native focus in the host. It reuses an existing fixture button: adding a test-only control
|
||||
changes the inline fixture geometry exercised by the earlier actionability checks.
|
||||
|
||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||
|
||||
@@ -347,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
### Pi-compatible forks with their own session directory
|
||||
### OMP profiles and Pi-compatible forks
|
||||
|
||||
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -361,6 +361,34 @@ OMP already ships as a built-in provider option. It is disabled by default; enab
|
||||
}
|
||||
```
|
||||
|
||||
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"omp-work": {
|
||||
"extends": "omp",
|
||||
"label": "Oh My Pi (Work)",
|
||||
"command": ["omp"],
|
||||
"env": {
|
||||
"XDG_CONFIG_HOME": "~/.config/omp-work",
|
||||
"XDG_STATE_HOME": "~/.local/state/omp-work"
|
||||
},
|
||||
"params": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
"slowModel": "anthropic/claude-opus-4-1",
|
||||
"planModel": "openai/o3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
```json
|
||||
@@ -380,7 +408,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Data Model
|
||||
|
||||
## Project identity
|
||||
|
||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||
|
||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
||||
service instead of constructing records independently. The workspace record is then the durable
|
||||
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
|
||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
@@ -58,8 +82,8 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
@@ -166,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
servicePorts?: { // optional dynamic service port allocation policy
|
||||
range?: string // inclusive range, e.g. "3000-4000"
|
||||
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
@@ -265,8 +293,8 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
|
||||
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
@@ -422,19 +450,22 @@ Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
||||
workspace `projectId` is authoritative when it names an active project, regardless of cwd
|
||||
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
|
||||
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
|
||||
workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
@@ -444,21 +475,25 @@ emptied duplicate.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
|
||||
@@ -40,6 +40,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
@@ -62,6 +64,8 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
@@ -242,6 +243,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
|
||||
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
|
||||
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
|
||||
that reads what it needs from `process.env` and invoke it as
|
||||
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
|
||||
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
@@ -278,6 +287,15 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
||||
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
||||
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
||||
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
||||
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
||||
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
||||
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
||||
so the port may already be bound. `portScript` takes precedence when both values are present.
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
@@ -374,11 +392,14 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
@@ -389,6 +410,11 @@ Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
default; pass `--server <server-id>` when targeting another server.
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
@@ -176,8 +176,9 @@ IPs and `localhost` are allowed by default.
|
||||
|
||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||
want to expose the daemon port directly.
|
||||
- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for
|
||||
untrusted networks or mobile access when you do not want to expose the daemon
|
||||
port directly.
|
||||
- The container is the isolation boundary for agents. Agents can read and write
|
||||
whatever you mount into `/workspace` and whatever credentials you place in
|
||||
`/home/paseo`.
|
||||
|
||||
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
@@ -70,7 +70,36 @@ Two escape hatches in the codebase:
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
||||
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
||||
ordinary portals regardless of `z-index`, which would hide app toasts and
|
||||
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||
lets tooltip portals paint above both.
|
||||
|
||||
The shared overlay scale is relative for interactive surfaces: a base floating
|
||||
panel is below a base modal, while a floating panel rendered from inside a modal
|
||||
inherits that modal's layer and paints above it. Wrap portal content in
|
||||
`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
|
||||
comboboxes must use `overlay-root` too. Rendering them through React Native
|
||||
Web's `<Modal>` puts them in the browser top layer, where no ordinary modal
|
||||
portal can cover them.
|
||||
|
||||
Painting and keyboard ownership use the same relative layer model. Register
|
||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
the React tree, carry the opener's current layer through the host store and
|
||||
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
|
||||
ownership silently reset at the app root. When the opener is a global keyboard
|
||||
action and has no component context to carry, resolve the host layer with
|
||||
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
|
||||
current top registered layer before the new host joins the stack; do not give a
|
||||
global dialog a fixed root-derived modal layer.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
@@ -123,6 +152,13 @@ lockstep, no re-measurement needed. Do not call
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
||||
leave the last height/progress pair stuck in either the open or closed state.
|
||||
Keep that terminal reconciliation on the UI thread; a later focus or blur must
|
||||
not be required to repair the offset.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
190
docs/forge-providers.md
Normal file
190
docs/forge-providers.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Adding a Git Forge to Paseo
|
||||
|
||||
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
|
||||
shared protocol messages carry neutral/open facts, the server adapter owns
|
||||
behavior, and the app owns bundled presentation/runtime interpretation.
|
||||
|
||||
The maintainer litmus test is the rule of thumb:
|
||||
|
||||
> Adding a new forge means adding files in a new directory/module that implement
|
||||
> an interface, plus one entry in the centralized registry/manifest for that
|
||||
> package.
|
||||
|
||||
## The Three Registrations
|
||||
|
||||
For forge `acme`, the expected end state is:
|
||||
|
||||
1. **Protocol manifest** - optional, only when the forge should be presented by
|
||||
shared manifest data. Add one `ForgeDefinition` to
|
||||
`packages/protocol/src/forge-manifest.ts`.
|
||||
|
||||
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
|
||||
implementing `ForgeService`, any adapter-owned fact types/guards/constants
|
||||
beside it, and one `defaultForgeRegistry` entry in
|
||||
`packages/server/src/services/forge-registry.ts`.
|
||||
|
||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
||||
logic consumers (URL builders, merge-capability, native checks, and the
|
||||
Node-based e2e harness) never pull the client rendering stack:
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
|
||||
optional `facts` (schema, merge-capability, native-check fallbacks). No
|
||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
||||
`packages/app/src/git/forges/index.ts`.
|
||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
||||
under `packages/app/src/components/icons/`), optional `brandColor`, optional
|
||||
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
|
||||
`packages/app/src/git/forges/view.ts`.
|
||||
|
||||
There should be no protocol typed-union arm, no central app icon/color/url/facts
|
||||
map, and no central server union of known forge facts.
|
||||
|
||||
## Protocol
|
||||
|
||||
`forgeSpecific` on PR status is an open envelope:
|
||||
|
||||
```ts
|
||||
z.object({ forge: z.string() }).passthrough();
|
||||
```
|
||||
|
||||
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
|
||||
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
|
||||
"gitea"` when they share the same facts shape, while top-level `status.forge`
|
||||
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
|
||||
|
||||
Protocol does not validate per-forge fact fields. Consumers that understand a
|
||||
facts family validate at runtime with their own schema/guard. Unknown or
|
||||
schema-mismatched facts render neutrally instead of failing the whole message
|
||||
parse. This is the version-skew win: an old client can receive facts from a
|
||||
newer forge and still show the PR/MR in a neutral state.
|
||||
|
||||
Shipped GitHub compatibility stays separate:
|
||||
|
||||
- `status.github` remains accepted for released peers.
|
||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
||||
into `status.github` for older clients.
|
||||
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
|
||||
actually produce the state.
|
||||
|
||||
## Server
|
||||
|
||||
The server-wide status type only promises:
|
||||
|
||||
```ts
|
||||
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
||||
```
|
||||
|
||||
Adapter-owned files define the typed shapes and guards, for example
|
||||
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
|
||||
strong internal types for construction and command guards, but shared server
|
||||
code must not grow a central list of forge fact arms.
|
||||
|
||||
Register the adapter in `defaultForgeRegistry` with:
|
||||
|
||||
- `createService`
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
|
||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
||||
behind, or contain commits that have not been pushed yet.
|
||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
||||
selecting the newest terminal request by branch alone can silently attach an
|
||||
old promotion or feature request to new work.
|
||||
|
||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
||||
previous commit's cached terminal status until the cache expires.
|
||||
|
||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||
host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
rendering stack:
|
||||
|
||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||
|
||||
- `id`
|
||||
- optional `urlGrammar`
|
||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||
|
||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||
|
||||
- `id`
|
||||
- `icon`
|
||||
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
|
||||
- optional `paneContributions`
|
||||
|
||||
Two registries live under `packages/app/src/git/forges/`:
|
||||
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
|
||||
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
|
||||
drives icon/color lookup and PR-pane contributions. Logic consumers must import
|
||||
the logic registry only — importing the view registry (or a `.view.tsx` module)
|
||||
from a logic path pulls react-native and breaks the Node-based e2e harness.
|
||||
|
||||
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
|
||||
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
|
||||
call sites use `withUnistyles` and a `uniProps` mapping such as:
|
||||
|
||||
```ts
|
||||
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
|
||||
```
|
||||
|
||||
Facts modules use one source of truth: a Zod schema. Helpers like
|
||||
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
|
||||
derive guards from `schema.safeParse` and re-parse before invoking typed
|
||||
derivers/renderers. That keeps typed derivers away from the open wire envelope.
|
||||
|
||||
## Checklist
|
||||
|
||||
To add `acme`:
|
||||
|
||||
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
|
||||
label, nouns, icon kind, sign-in CLI, or cloud hosts.
|
||||
2. Add `acme-service.ts` implementing `ForgeService`.
|
||||
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
|
||||
4. Add one `defaultForgeRegistry` entry.
|
||||
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
|
||||
`packages/app/src/git/forges/acme.view.tsx` (view).
|
||||
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
|
||||
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
|
||||
7. Add/update the icon component only if the client bundle should show a brand
|
||||
mark.
|
||||
8. If the forge's CI/data model does not fit an existing required
|
||||
`ForgeService` field, widen the shared interface (plus the protocol schema
|
||||
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
|
||||
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
|
||||
`workflowRunId` as the alternative address. Expect this step to touch
|
||||
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
|
||||
adapters. Widening a shared field is not forge-local: it also affects the
|
||||
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
|
||||
`forgeCheckDetails`), so verify every consumer rather than assuming the change
|
||||
only reaches the new adapter.
|
||||
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
|
||||
checkout PR schema, app forge URL/presentation tests, app merge capability,
|
||||
and any PR-pane native data tests touched.
|
||||
|
||||
Run `npm run typecheck` after each implementation slice. If protocol or client
|
||||
declarations are stale, run `npm run build:client`; if server/CLI declarations
|
||||
are stale, run `npm run build:server`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- GitHub is a normal registry entry plus released compatibility shims. Keep all
|
||||
real shims tagged with `COMPAT(name)`.
|
||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
||||
top-level brand is Forgejo or Codeberg.
|
||||
- Brand icons are bundled React components, so they cannot come from protocol
|
||||
manifest data.
|
||||
- Source URL grammars are app-side because blob/tree path syntax is
|
||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
||||
rather than constructing a wrong URL.
|
||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||
not protocol.
|
||||
@@ -2,39 +2,43 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
|
||||
86
docs/hub.md
Normal file
86
docs/hub.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Paseo Hub relationship
|
||||
|
||||
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect <url> --token <token>` from the daemon machine.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub.
|
||||
The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional
|
||||
encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch,
|
||||
or reconnects.
|
||||
|
||||
The daemon persists a relationship ID and private connection credential before enrollment. The
|
||||
relationship is independent of its current transport, so a future transport can replace the direct
|
||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||
|
||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
|
||||
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
|
||||
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
|
||||
shape.
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
|
||||
|
||||
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
|
||||
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
|
||||
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
|
||||
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
|
||||
response returns the existing agent and its current state; there is no separate reconciliation RPC.
|
||||
Transient stream frames are not durably replayed.
|
||||
|
||||
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
|
||||
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
|
||||
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
Hub creates use the same agent creation path as trusted clients. They may select any existing
|
||||
worktree target shape. Execution completion policy remains outside the daemon: a completed agent
|
||||
turn does not imply that the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
|
||||
actions are idempotent and continue to resolve from stored ownership after daemon restart.
|
||||
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
|
||||
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
|
||||
workspace through the shared workspace archive service, so the backing directory is removed only
|
||||
after its final active workspace reference disappears. Local and shared checkouts archive only the
|
||||
execution-owned agent.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter.
|
||||
Daemon restart loads the same relationship and credential and reconnects without another enrollment
|
||||
ceremony.
|
||||
|
||||
Hub authentication rejection or close code `4403` permanently revokes the local relationship. The
|
||||
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
|
||||
scopes, and a sanitized reason for status reporting.
|
||||
|
||||
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
|
||||
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
|
||||
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
|
||||
## Cross-repository compatibility
|
||||
|
||||
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
|
||||
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
|
||||
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
|
||||
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
|
||||
@@ -37,6 +37,15 @@ npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
|
||||
The parity test catches missing keys across English and every supported locale resource.
|
||||
|
||||
## Forge-Variant Copy
|
||||
|
||||
Strings that vary by git forge follow a two-tier rule:
|
||||
|
||||
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
|
||||
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
|
||||
|
||||
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
|
||||
|
||||
@@ -43,7 +43,8 @@ This architecture means:
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
|
||||
## Target user
|
||||
|
||||
@@ -73,7 +74,7 @@ Anyone who builds software:
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
|
||||
@@ -16,7 +16,7 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
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.
|
||||
|
||||
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
@@ -32,9 +32,9 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
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`.
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
@@ -44,6 +44,8 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -12,6 +12,8 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
@@ -36,25 +38,69 @@ There are two supported ways to ship from `main`:
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
|
||||
## Standard release (patch)
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
|
||||
Before running any stable patch release command:
|
||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
||||
a beta is promoted or superseded by a direct stable release.
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
diff. The highest-impact change determines the version:
|
||||
|
||||
- **Minor** — a user would experience the release as a significant upgrade. This
|
||||
includes substantial new workflows, providers, forges, platforms, integrations,
|
||||
or meaningful expansions of existing capabilities. Foundational internal work
|
||||
also qualifies when it materially changes reliability, performance,
|
||||
compatibility, deployment, or operation; diff size alone does not.
|
||||
- **Patch** — fixes, polish, small enhancements, and reliability or performance
|
||||
improvements within existing capabilities. Follow-up corrections to a minor
|
||||
release are patches.
|
||||
|
||||
The release agent selects patch or minor during preparation and presents the
|
||||
target version with the changelog for approval. Agents never select a major
|
||||
version autonomously. A major release requires an explicit user instruction and
|
||||
approval; Paseo remains on major version zero until that deliberate decision.
|
||||
|
||||
Version bumps are never used to retry a failed build. Retry the existing version
|
||||
as described in **Fixing a failed release build**.
|
||||
|
||||
## Standard release (stable)
|
||||
|
||||
Before running any stable release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
# Run exactly one, matching the approved decision:
|
||||
npm run release:patch
|
||||
npm run release:minor
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
|
||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||
stable release complete.
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**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).
|
||||
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
@@ -63,15 +109,19 @@ Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fl
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
# Run exactly one approved version command:
|
||||
npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
|
||||
npm run release:beta:patch # Start the next patch beta line
|
||||
npm run release:beta:minor # Start the next minor beta line
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
@@ -107,7 +157,7 @@ Updater clients only discover a release through those `.yml` manifests, so there
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
@@ -127,7 +177,7 @@ gh workflow run desktop-rollout.yml \
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
@@ -165,17 +215,17 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||
@@ -463,7 +513,8 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
@@ -472,11 +523,13 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
checkout.forge.set_auto_merge.request;
|
||||
checkout.forge.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Namespace segment: `forge`
|
||||
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||
- Direction: `request` or `response`
|
||||
|
||||
@@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
checkout.forge.set_auto_merge.request;
|
||||
// -> checkout.forge.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
@@ -33,7 +33,7 @@ Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
type: "checkout.forge.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
@@ -45,7 +45,7 @@ Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
type: "checkout.forge.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
@@ -58,14 +58,16 @@ Responses put correlated result data under `payload`:
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Provider Namespacing
|
||||
## Forge Namespacing
|
||||
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
|
||||
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
- `checkout.forge.*` for operations whose request/response shape is genuinely
|
||||
forge-neutral and whose implementation dispatches through the forge resolver.
|
||||
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
|
||||
callers migrate to the neutral `checkout.forge.*` shape
|
||||
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ dev--feature-auth--miniweb.localhost
|
||||
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Managing workspace scripts
|
||||
|
||||
Configured `paseo.json` scripts can be managed without addressing their backing terminal directly:
|
||||
|
||||
```bash
|
||||
paseo script ls [--cwd <path> | --workspace <workspace-id>]
|
||||
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
|
||||
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
|
||||
```
|
||||
|
||||
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
@@ -95,6 +107,29 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here.
|
||||
|
||||
## Forwarded headers
|
||||
|
||||
Paseo sets these when it forwards a request to a workspace service:
|
||||
|
||||
| Header | Value |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one |
|
||||
| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) |
|
||||
| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
|
||||
| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set |
|
||||
|
||||
`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched.
|
||||
|
||||
Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`.
|
||||
|
||||
### The forwarded authority is not authenticated
|
||||
|
||||
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through.
|
||||
|
||||
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
|
||||
@@ -32,9 +32,14 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
|
||||
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
|
||||
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
|
||||
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
|
||||
queue wait, execution time, failures, timeouts, and top operations),
|
||||
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
|
||||
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
|
||||
command timeout begins only after a command acquires a limiter slot.
|
||||
|
||||
## Known remaining contention (follow-up candidates)
|
||||
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||
|
||||
@@ -124,6 +124,16 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
|
||||
npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
```
|
||||
|
||||
### Browser tab bridge regression
|
||||
|
||||
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
|
||||
|
||||
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
@@ -167,6 +177,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -51,9 +51,55 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The host runtime owns each session replica for as long as the host remains registered. React
|
||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
|
||||
version.
|
||||
|
||||
## Projected pages reconcile with live presentation
|
||||
|
||||
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
|
||||
rows already received live—for example, a tool call retained at its original display position while
|
||||
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
|
||||
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||
instead of relocating visible user messages around newly fetched history.
|
||||
|
||||
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
|
||||
@@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u
|
||||
|
||||
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||
|
||||
### Do Not Materialize Styles At Module Scope
|
||||
|
||||
Never read a Unistyles style property into a module-level constant. This includes cached arrays:
|
||||
|
||||
```tsx
|
||||
// Wrong: evaluated while the app may still be using the temporary system theme.
|
||||
const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
|
||||
// Right: each style proxy is read when this view renders.
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} />;
|
||||
```
|
||||
|
||||
Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A
|
||||
module-level read can therefore materialize the light style before a persisted dark theme is
|
||||
active. If the view mounts after that theme change, React Native receives the stale light object;
|
||||
Unistyles registers the node for future changes but does not retroactively replace its initial
|
||||
props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly
|
||||
this reason.
|
||||
|
||||
Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule.
|
||||
Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside
|
||||
JSX, create the array inside the component or in a `useMemo` that first runs when the component
|
||||
mounts—never at module evaluation time.
|
||||
|
||||
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||
|
||||
## Dynamic Pixel Styles On Web
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
|
||||
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
autoPatchelfHook,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
@@ -59,10 +60,13 @@ buildNpmPackage rec {
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
@@ -71,10 +75,9 @@ buildNpmPackage rec {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
|
||||
# speech runtime ships prebuilt platform packages and is copied into the
|
||||
# daemon closure by scripts/trace-daemon.mjs.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
@@ -89,7 +92,7 @@ buildNpmPackage rec {
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# forked terminal/speech worker processes) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
|
||||
210
package-lock.json
generated
210
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2531,10 +2531,35 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2554,22 +2579,33 @@
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
@@ -11055,6 +11091,19 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@replit/codemirror-vim": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@codemirror/commands": "6.x.x",
|
||||
"@codemirror/language": "6.x.x",
|
||||
"@codemirror/search": "6.x.x",
|
||||
"@codemirror/state": "6.x.x",
|
||||
"@codemirror/view": "6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
@@ -35162,8 +35211,13 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -35178,6 +35232,7 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -36181,18 +36236,19 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/server": "0.1.109",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/server": "0.2.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.4"
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -36432,10 +36488,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36446,7 +36502,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36689,7 +36745,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37585,9 +37641,9 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37817,7 +37873,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37830,7 +37886,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38048,15 +38104,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/highlight": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/highlight": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38100,22 +38156,22 @@
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz",
|
||||
"integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -38123,10 +38179,10 @@
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38136,10 +38192,10 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38149,10 +38205,10 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38162,10 +38218,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz",
|
||||
"integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38175,10 +38231,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38188,10 +38244,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz",
|
||||
"integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38201,10 +38257,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz",
|
||||
"integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38214,10 +38270,10 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.195",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz",
|
||||
"integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==",
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38593,7 +38649,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -49,9 +49,9 @@
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
@@ -132,6 +132,8 @@
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -130,13 +130,6 @@ 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: {
|
||||
|
||||
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectLoadedTimelineDoesNotScroll,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptVisible,
|
||||
holdNextOlderTimelinePage,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
@@ -25,4 +28,21 @@ test.describe("Agent timeline pagination", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
try {
|
||||
await makeLoadedTimelineFitViewport(page);
|
||||
const olderPage = await holdNextOlderTimelinePage(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectLoadedTimelineDoesNotScroll(page);
|
||||
await olderPage.expectLoading();
|
||||
olderPage.release();
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { openSessions } from "./helpers/archive-tab";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "./helpers/rewind-flow";
|
||||
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface TimelineClient extends SeedDaemonClient {
|
||||
fetchAgentTimeline(
|
||||
agentId: string,
|
||||
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||
|
||||
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||
}
|
||||
|
||||
test.describe("archived Codex agent recovery", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
await sendMessage(handle, INITIAL_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await handle.client.archiveAgent(handle.agentId);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const timelineClient = handle.client as TimelineClient;
|
||||
await expect(
|
||||
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 100,
|
||||
}),
|
||||
).rejects.toThrow(/archiv/i);
|
||||
await expect
|
||||
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||
]);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -123,4 +123,16 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector closes after model selection", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
|
||||
|
||||
await sheet.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,9 +55,15 @@ test.describe("Command center workspaces", () => {
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(WORKSPACE_TITLE);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toContainText(WORKSPACE_BRANCH);
|
||||
|
||||
// The subtitle disambiguates by project: host · project · branch (multi-host).
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(subtitle).toContainText(seeded.projectDisplayName);
|
||||
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
|
||||
|
||||
// The agent subtitle is unchanged by the shared-helper refactor.
|
||||
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(agentRow).toContainText(AGENT_TITLE);
|
||||
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
|
||||
@@ -89,6 +95,10 @@ test.describe("Command center workspaces", () => {
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
// The project name is now part of the workspace searchText.
|
||||
await input.fill(seeded.projectDisplayName);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await input.fill(AGENT_TITLE);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
@@ -103,4 +113,38 @@ test.describe("Command center workspaces", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-single-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// No secondary host: with a single host, the host label is gated away.
|
||||
await gotoAppShell(page);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
86
packages/app/e2e/commit-diff-panel.spec.ts
Normal file
86
packages/app/e2e/commit-diff-panel.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
test("commit history explains when the workspace has no commits ahead of its base", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
|
||||
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
|
||||
"No commits ahead of main yet",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("commit history shows dates and shares diff layout preferences", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
|
||||
await createFeatureCommit(workspace.repoPath);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
|
||||
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
|
||||
hasText: COMMIT_SUBJECT,
|
||||
});
|
||||
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
|
||||
await expect(commitRow).toContainText("Jan 15");
|
||||
await commitRow.click();
|
||||
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
@@ -10,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -218,6 +223,99 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
await page.getByTestId("diff-file-1-actions").click();
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||
await changesTabToggle.click();
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||
"Scroll long lines",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||
"zz-deleted.ts",
|
||||
);
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("Deleted");
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -243,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
@@ -250,6 +353,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
const changesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "changes-options-menu"),
|
||||
readSvgRight(page, "diff-file-0-actions"),
|
||||
]);
|
||||
expectAligned(changesRightRail);
|
||||
|
||||
const [folderStat, fileStat] = await Promise.all([
|
||||
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(folderStat).not.toBeNull();
|
||||
expect(fileStat).not.toBeNull();
|
||||
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
|
||||
const filesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "files-refresh"),
|
||||
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||
]);
|
||||
expectAligned(filesRightRail);
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(sortLabel).not.toBeNull();
|
||||
expect(firstRowIcon).not.toBeNull();
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
async function createWorkspaceWithMountedTabDiff(
|
||||
options: WorkspaceFixtureOptions = {},
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -436,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not measure ${testID}`);
|
||||
}
|
||||
return box.x + box.width;
|
||||
}
|
||||
|
||||
function expectAligned(values: number[]): void {
|
||||
const [first, ...rest] = values;
|
||||
for (const value of rest) {
|
||||
expect(value).toBeCloseTo(first, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
|
||||
19
packages/app/e2e/directory-bootstrap.spec.ts
Normal file
19
packages/app/e2e/directory-bootstrap.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test } from "./fixtures";
|
||||
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
|
||||
|
||||
test.describe("Directory bootstrap correctness", () => {
|
||||
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const scenario = await DirectoryBootstrapScenario.open(page);
|
||||
try {
|
||||
await scenario.expectDirectoryStarts(1);
|
||||
await scenario.stayConnectedWithoutRefetchAndApplyDeltas();
|
||||
await scenario.disconnectMutateAndReconnect();
|
||||
await scenario.expectVisibleReconciliationAndNavigateAgent();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
422
packages/app/e2e/file-editing.spec.ts
Normal file
422
packages/app/e2e/file-editing.spec.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
function fitsViewportWidth(element: HTMLElement): boolean {
|
||||
return element.scrollWidth === element.clientWidth;
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(target: string) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-editing-chat-link-",
|
||||
title: "Chat file link e2e",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "target.ts"),
|
||||
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
const fileLink = page.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expectFileTabOpen(page, "target.ts");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(fileLink).toBeVisible();
|
||||
await fileLink.click();
|
||||
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await editor(page).click();
|
||||
await page.keyboard.press("Alt+Shift+W");
|
||||
|
||||
await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||
const relativePath = "src/deep/visuals.md";
|
||||
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||
await openFileFromExplorer(page, "visuals.md");
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||
await fileTab.hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||
|
||||
await content.click();
|
||||
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
});
|
||||
|
||||
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
||||
});
|
||||
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
||||
const relativePath = "tooltip-font.txt";
|
||||
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, relativePath);
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
||||
.getByText(relativePath, { exact: true }),
|
||||
).toHaveCSS("font-family", "monospace");
|
||||
});
|
||||
|
||||
test("wraps Markdown while source code remains horizontally scrollable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-wrap-" });
|
||||
const longLine = "word ".repeat(300);
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "source.ts"),
|
||||
`const value = "${longLine}";\n`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-scroller");
|
||||
await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true);
|
||||
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
const sourceScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-scroller");
|
||||
await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await Promise.all(
|
||||
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||
),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||
|
||||
await replaceEditorText(page, "const immediate = 3;\n");
|
||||
await editor(page).press("Control+s");
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||
|
||||
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const external = 4;");
|
||||
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||
|
||||
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||
await gate.drop();
|
||||
gate.restore();
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(subscriptionCount);
|
||||
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||
});
|
||||
|
||||
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "windows.ts");
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "windows.ts");
|
||||
|
||||
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
|
||||
await editor(page).press("Control+s");
|
||||
|
||||
const expected = Buffer.from(
|
||||
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
|
||||
"utf8",
|
||||
).toString("hex");
|
||||
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
|
||||
});
|
||||
|
||||
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "draft.ts");
|
||||
|
||||
await replaceEditorText(page, "const local = 2;\n");
|
||||
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_draft.ts")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||
|
||||
await page.goto("/settings/editor");
|
||||
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "vim.ts");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
await editor(page).click();
|
||||
await editor(page).press("i");
|
||||
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||
await editor(page).press("Escape");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
export interface WaitForServerOptions {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
@@ -22,6 +22,8 @@ interface WaitForServerOptions {
|
||||
getRecentOutput?: () => string;
|
||||
}
|
||||
|
||||
type ServerProbe = (host: string, port: number) => Promise<void>;
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -75,7 +77,25 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
async function connectToServer(host: string, port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
port: number,
|
||||
options: WaitForServerOptions,
|
||||
probe: ServerProbe = connectToServer,
|
||||
): Promise<void> {
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
@@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
await probe(host, port);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
@@ -116,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
);
|
||||
}
|
||||
|
||||
async function probeMetro(host: string, port: number): Promise<void> {
|
||||
const response = await fetch(`http://${host}:${port}/status`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
const body = (await response.text()).trim();
|
||||
if (response.status !== 200 || body !== "packager-status:running") {
|
||||
throw new Error(
|
||||
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
await waitForServer(port, options, probeMetro);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
@@ -302,6 +328,12 @@ interface PairingDaemonClient {
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
} catch {
|
||||
// The local PR fixture below remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -323,6 +355,70 @@ if (recordPath) {
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
const fakeGhSource = `#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const args = process.argv.slice(2);
|
||||
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).stdout?.trim();
|
||||
|
||||
if (origin === fixtureRemote) {
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
if (command === "auth status") process.exit(0);
|
||||
if (command === "repo view") {
|
||||
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "issue list") {
|
||||
process.stdout.write("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const pr = {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||
if (!realGhPath) process.exit(127);
|
||||
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -556,13 +652,19 @@ async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<nu
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
interface RelayPorts {
|
||||
relayPort: number;
|
||||
inspectorPort: number;
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<RelayPorts> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -576,6 +678,10 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--inspector-ip",
|
||||
"127.0.0.1",
|
||||
"--inspector-port",
|
||||
String(inspectorPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
@@ -590,7 +696,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
|
||||
try {
|
||||
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||
return relayPort;
|
||||
return { relayPort, inspectorPort };
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
@@ -767,12 +873,19 @@ export default async function globalSetup() {
|
||||
await logSpeechHarnessConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro({
|
||||
metroPort,
|
||||
daemonPort: port,
|
||||
buffer: metroLineBuffer,
|
||||
});
|
||||
await waitForMetro(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
});
|
||||
|
||||
const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort]));
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
@@ -783,19 +896,11 @@ export default async function globalSetup() {
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
await waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
});
|
||||
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
@@ -809,7 +914,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
|
||||
@@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate {
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentTimelineResponseGate {
|
||||
release(): void;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ interface E2EDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||
function resolveDaemonWsUrl(port?: number): string {
|
||||
return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
): Promise<ClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||
const client = new DaemonClient({
|
||||
url: resolveDaemonWsUrl(),
|
||||
url: resolveDaemonWsUrl(options.port),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
114
packages/app/e2e/helpers/daemon-websocket-gate.ts
Normal file
114
packages/app/e2e/helpers/daemon-websocket-gate.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface DirectoryBootstrapCounts {
|
||||
agents: number;
|
||||
workspaces: number;
|
||||
}
|
||||
|
||||
export interface DirectoryRequestStartCounts {
|
||||
subscribed: DirectoryBootstrapCounts;
|
||||
unsubscribed: DirectoryBootstrapCounts;
|
||||
total: DirectoryBootstrapCounts;
|
||||
}
|
||||
|
||||
interface ClientRequest {
|
||||
type?: unknown;
|
||||
subscribe?: unknown;
|
||||
page?: { cursor?: unknown };
|
||||
}
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
|
||||
if (request.page?.cursor) return null;
|
||||
if (request.type === "fetch_agents_request") return "agents";
|
||||
if (request.type === "fetch_workspaces_request") return "workspaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
const request = readClientRequest(message);
|
||||
if (typeof request?.type === "string") {
|
||||
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
||||
const directory = directoryForRequest(request);
|
||||
if (directory) {
|
||||
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
|
||||
directoryStarts[subscription][directory] += 1;
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(
|
||||
sockets.map((ws) =>
|
||||
ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
||||
return {
|
||||
subscribed: { ...directoryStarts.subscribed },
|
||||
unsubscribed: { ...directoryStarts.unsubscribed },
|
||||
total: { ...directoryStarts.total },
|
||||
};
|
||||
},
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
|
||||
daemonListen?: string;
|
||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||
hangDaemonStart?: boolean;
|
||||
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||
desktopSettingsDelayMs?: number;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
@@ -101,6 +103,7 @@ declare global {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
__desktopDaemonStartRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
window.__desktopDaemonStartRequested = false;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
window.__desktopDaemonStartRequested = true;
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
async function waitForDesktopSettingsResponse() {
|
||||
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
await waitForDesktopSettingsResponse();
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||
// Give the startup state two paints to expose any app → splash regression.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
|
||||
155
packages/app/e2e/helpers/directory-bootstrap-scenario.ts
Normal file
155
packages/app/e2e/helpers/directory-bootstrap-scenario.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
interface SeededDirectoryAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
async function createRunningMockAgent(
|
||||
workspace: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<SeededDirectoryAgent> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: `Keep ${title} running for directory synchronization.`,
|
||||
});
|
||||
const running = await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000,
|
||||
);
|
||||
expect(running.status).toBe("running");
|
||||
return { id: agent.id, title };
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open command center" }).click();
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
private readonly workspaces: SeededWorkspace[] = [];
|
||||
private disconnectedWorkspace: SeededWorkspace | null = null;
|
||||
private disconnectedAgent: SeededDirectoryAgent | null = null;
|
||||
|
||||
private constructor(
|
||||
private readonly page: Page,
|
||||
private readonly gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {}
|
||||
|
||||
static async open(page: Page): Promise<DirectoryBootstrapScenario> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const scenario = new DirectoryBootstrapScenario(page, gate);
|
||||
const workspace = await scenario.seedWorkspace("directory-bootstrap-initial-");
|
||||
const agent = await createRunningMockAgent(workspace, "Initial directory agent");
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, workspace.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page.getByRole("button", { name: agent.title, exact: true })).toBeVisible();
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async expectDirectoryStarts(expectedPerDirectory: number): Promise<void> {
|
||||
await expect
|
||||
.poll(() => this.gate.getDirectoryRequestStartCounts())
|
||||
.toEqual({
|
||||
subscribed: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
|
||||
});
|
||||
}
|
||||
|
||||
async stayConnectedWithoutRefetchAndApplyDeltas(): Promise<void> {
|
||||
const workspace = await this.seedWorkspace("directory-bootstrap-background-");
|
||||
const agent = await createRunningMockAgent(workspace, "Background directory agent");
|
||||
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.expectDirectoryStarts(1);
|
||||
}
|
||||
|
||||
async disconnectMutateAndReconnect(): Promise<void> {
|
||||
await this.gate.drop();
|
||||
await expectReconnectingToastVisible(this.page);
|
||||
|
||||
this.disconnectedWorkspace = await this.seedWorkspace("directory-bootstrap-reconnect-");
|
||||
this.disconnectedAgent = await createRunningMockAgent(
|
||||
this.disconnectedWorkspace,
|
||||
"Reconnected directory agent",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByText(this.disconnectedWorkspace.projectDisplayName, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(this.page.getByText(this.disconnectedAgent.title, { exact: true })).toHaveCount(0);
|
||||
|
||||
this.gate.restore();
|
||||
await expectReconnectingToastGone(this.page);
|
||||
await this.expectDirectoryStarts(2);
|
||||
}
|
||||
|
||||
async expectVisibleReconciliationAndNavigateAgent(): Promise<void> {
|
||||
const workspace = this.requireDisconnectedWorkspace();
|
||||
const agent = this.requireDisconnectedAgent();
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await agentLink.click();
|
||||
await expect(this.page).toHaveURL(
|
||||
new RegExp(
|
||||
`/workspace/${workspace.workspaceId}/agent/${agent.id}|/workspace/${workspace.workspaceId}`,
|
||||
),
|
||||
);
|
||||
await expect(this.page.getByRole("button", { name: agent.title, exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const pings = this.gate.getClientRequestCount("ping");
|
||||
await expect
|
||||
.poll(() => this.gate.getClientRequestCount("ping"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(pings);
|
||||
await this.expectDirectoryStarts(2);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.gate.restore();
|
||||
await Promise.all(this.workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
|
||||
private async seedWorkspace(prefix: string): Promise<SeededWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: prefix });
|
||||
this.workspaces.push(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private requireDisconnectedWorkspace(): SeededWorkspace {
|
||||
if (!this.disconnectedWorkspace) throw new Error("Reconnect workspace was not seeded.");
|
||||
return this.disconnectedWorkspace;
|
||||
}
|
||||
|
||||
private requireDisconnectedAgent(): SeededDirectoryAgent {
|
||||
if (!this.disconnectedAgent) throw new Error("Reconnect agent was not seeded.");
|
||||
return this.disconnectedAgent;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalGhPrFixture {
|
||||
pr: GhPrFixture;
|
||||
mainCheckout: GhDefaultBranchClone;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
@@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||
const basePath = path.join(fixtureRoot, "base");
|
||||
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
await mkdir(basePath);
|
||||
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||
git(["add", "pr-1.txt"], basePath);
|
||||
git(["commit", "-m", "Add PR 1"], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
|
||||
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||
execFileSync(
|
||||
"git",
|
||||
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||
|
||||
return {
|
||||
pr: {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
branch: "pr-branch-1",
|
||||
localPath: basePath,
|
||||
},
|
||||
mainCheckout: {
|
||||
path: checkoutPath,
|
||||
cleanup: async () => {},
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -85,43 +86,61 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
child = await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await stopProcess(child);
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
export async function connectNewWorkspaceDaemonClient(options?: {
|
||||
port?: number;
|
||||
}): Promise<NewWorkspaceDaemonClient> {
|
||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||
clientIdPrefix: "app-e2e-new-workspace",
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(searchInput).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
projectDisplayName: string,
|
||||
@@ -287,6 +298,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(name);
|
||||
await selectBranchInPicker(page, name);
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
@@ -348,6 +366,18 @@ export async function expectComposerGithubAttachmentPill(
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function pasteGithubPrUrl(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -158,10 +158,11 @@ export interface SeedDaemonClient {
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||
export async function connectSeedClient(options?: { port?: number }): Promise<SeedDaemonClient> {
|
||||
return connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
port: options?.port,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -39,6 +39,13 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function pinWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
|
||||
const pinItem = page.getByTestId(`sidebar-workspace-menu-pin-${serverId}:${workspaceId}`);
|
||||
await expect(pinItem).toBeVisible({ timeout: 10_000 });
|
||||
await pinItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
|
||||
55
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
55
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SessionMessage {
|
||||
type?: unknown;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface TimelineSubscriptionWaitOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: SessionMessage;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function observeTimelineSubscriptions(page: Page) {
|
||||
let acknowledgedAgentIds: string[] | null = null;
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const message = readSessionMessage(payload);
|
||||
if (message?.type !== "agent.timeline.set_subscription.response") return;
|
||||
const response = message.payload as { agentIds?: unknown } | undefined;
|
||||
if (!Array.isArray(response?.agentIds)) return;
|
||||
acknowledgedAgentIds = response.agentIds.filter(
|
||||
(agentId): agentId is string => typeof agentId === "string",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(
|
||||
agentIds: string[],
|
||||
options: TimelineSubscriptionWaitOptions = {},
|
||||
): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||
timeout: options.timeout ?? 15_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentOlderTimelineResponse,
|
||||
type AgentTimelineResponseGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
@@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight <= element.clientHeight;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
|
||||
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
|
||||
return {
|
||||
...gate,
|
||||
async expectLoading() {
|
||||
await gate.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
|
||||
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
|
||||
const option = page
|
||||
.getByRole("dialog")
|
||||
.last()
|
||||
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
|
||||
.first();
|
||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||
await option.click({ force: true });
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
openNewWorkspaceProjectPickerWithShortcut,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
146
packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts
Normal file
146
packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute } from "./helpers/mock-agent";
|
||||
import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject } from "./helpers/new-workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The draft mode control in New Workspace only mutates local form state; it never sends
|
||||
// set_agent_mode_request. So the only source of such a request while the New Workspace
|
||||
// composer is focused is a *live* agent's mode control. Recording those, keyed by agentId,
|
||||
// gives a direct signal that Shift+Tab leaked into a backgrounded agent.
|
||||
async function recordSetAgentModeRequests(page: Page): Promise<{
|
||||
requestsForAgent(agentId: string): Array<{ agentId: string; modeId: string }>;
|
||||
}> {
|
||||
const seen: Array<{ agentId: string; modeId: string }> = [];
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "set_agent_mode_request") {
|
||||
const agentId = typeof sessionMessage.agentId === "string" ? sessionMessage.agentId : "";
|
||||
const modeId = typeof sessionMessage.modeId === "string" ? sessionMessage.modeId : "";
|
||||
seen.push({ agentId, modeId });
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
return {
|
||||
requestsForAgent: (agentId: string) => seen.filter((request) => request.agentId === agentId),
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
// Focus the New Workspace composer and cycle the execution mode with the keyboard.
|
||||
// Kept out of the test body so the test reads as intent rather than key mechanics.
|
||||
async function cycleNewWorkspaceMode(page: Page, presses: number): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.click();
|
||||
for (let i = 0; i < presses; i++) {
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("New Workspace mode cycle safety", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
// Regression guard for the P1 safety bug: cycling the execution mode with Shift+Tab in
|
||||
// the New Workspace composer must never reach a backgrounded, still-mounted agent's mode
|
||||
// control and silently change that (possibly running) agent's mode — e.g. into a
|
||||
// permissive/bypass mode. See use-keyboard-action-handler.ts.
|
||||
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
|
||||
await seedCodexDefaultPreferences(page, serverId);
|
||||
const modeRequests = await recordSetAgentModeRequests(page);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "mode cycle safety e2e",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
|
||||
// Mount the live agent tab: its mode control registers a mode-cycle keyboard handler.
|
||||
await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id });
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Move to the New Workspace composer. The agent tab stays mounted in the background,
|
||||
// so its handler is still registered when we cycle here.
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await cycleNewWorkspaceMode(page, 6);
|
||||
|
||||
// fetchAgents is a real daemon round-trip; once it resolves, any mode change the
|
||||
// presses would have triggered has already landed. Assert the running agent is
|
||||
// untouched — both its committed mode and on the wire — with no fixed sleep.
|
||||
const agents = await seeded.client.fetchAgents();
|
||||
const backgroundAgent = agents.entries.find((entry) => entry.agent.id === agent.id)?.agent;
|
||||
expect(backgroundAgent?.currentModeId).toBe("auto");
|
||||
expect(modeRequests.requestsForAgent(agent.id)).toEqual([]);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
@@ -30,9 +33,11 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
createTempGithubRepo,
|
||||
hasGithubAuth,
|
||||
type LocalGhPrFixture,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const fixture of localGithubFixtures) {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
localGithubFixtures.clear();
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
@@ -807,6 +820,100 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
});
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: worktree.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await searchAndSelectBranchInPicker(page, "main");
|
||||
await expectPickerSelected(page, "main");
|
||||
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||
await expectPickerSelected(page, "main");
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
@@ -59,10 +60,37 @@ async function closeTopSheet(page: Page) {
|
||||
|
||||
async function closeSheetByHeaderButton(page: Page, testId: string) {
|
||||
const sheet = page.getByTestId(testId);
|
||||
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
|
||||
await sheet.getByLabel("Close", { exact: true }).click();
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) {
|
||||
const frontCoversBack = await page.evaluate(
|
||||
({ frontTestId: frontId, backTestId: backId }) => {
|
||||
const front = document.querySelector(`[data-testid="${frontId}"]`);
|
||||
const back = document.querySelector(`[data-testid="${backId}"]`);
|
||||
if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false;
|
||||
|
||||
const frontRect = front.getBoundingClientRect();
|
||||
const backRect = back.getBoundingClientRect();
|
||||
const left = Math.max(frontRect.left, backRect.left);
|
||||
const right = Math.min(frontRect.right, backRect.right);
|
||||
const top = Math.max(frontRect.top, backRect.top);
|
||||
const bottom = Math.min(frontRect.bottom, backRect.bottom);
|
||||
if (left >= right || top >= bottom) return false;
|
||||
|
||||
const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
return topElement != null && front.contains(topElement);
|
||||
},
|
||||
{ frontTestId, backTestId },
|
||||
);
|
||||
expect(frontCoversBack).toBe(true);
|
||||
}
|
||||
|
||||
async function hasFocusWithin(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((element) => element.contains(document.activeElement));
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
@@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expectProviderSettingsVisible(page);
|
||||
}
|
||||
|
||||
test.describe("provider settings bottom-sheet stack", () => {
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-modal-layer-",
|
||||
title: "Provider modal layer e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
const selector = page.getByTestId("combobox-desktop-container");
|
||||
await expect(selector).toBeVisible({ timeout: 10_000 });
|
||||
const searchInput = page.getByRole("textbox", { name: /search models/i });
|
||||
await expect(searchInput).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect.poll(() => hasFocusWithin(selector)).toBe(true);
|
||||
|
||||
await page.keyboard.press("Shift+?");
|
||||
const shortcuts = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
await expect(shortcuts).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(shortcuts).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
const commandCenter = page.getByTestId("command-center-panel");
|
||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||
await expect(commandCenter.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(commandCenter).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
const settingsButton = page.getByTestId("selector-header-settings-mock");
|
||||
await settingsButton.click();
|
||||
|
||||
const settings = page.getByTestId("provider-settings-sheet");
|
||||
await expect(settings).toBeVisible({ timeout: 10_000 });
|
||||
await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(settings).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(settingsButton).toBeFocused();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => {
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const search = page.getByPlaceholder("Search shortcuts");
|
||||
|
||||
await search.fill("command+n");
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
|
||||
|
||||
await search.fill("interrupt");
|
||||
|
||||
await expect(dialog.getByText("Interrupt agent", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toHaveCount(0);
|
||||
|
||||
await search.fill("no matching shortcut");
|
||||
await expect(dialog.getByText("No results found", { exact: true })).toBeVisible();
|
||||
|
||||
await search.fill("");
|
||||
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
71
packages/app/e2e/sidebar-reorder.spec.ts
Normal file
71
packages/app/e2e/sidebar-reorder.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
async function rowTestIds(rows: Locator) {
|
||||
return rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
}
|
||||
|
||||
async function visibleBoundingBox(row: Locator) {
|
||||
const box = await row.boundingBox();
|
||||
if (!box) throw new Error("Expected a visible draggable row");
|
||||
return box;
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
const targetBox = await visibleBoundingBox(rows.nth(1));
|
||||
|
||||
const page = rows.page();
|
||||
const source = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 };
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
try {
|
||||
const secondWorkspace = await firstProject.client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: firstProject.repoPath,
|
||||
projectId: firstProject.projectId,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to seed a second workspace");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]'));
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await firstProject.cleanup();
|
||||
await secondProject.cleanup();
|
||||
}
|
||||
});
|
||||
27
packages/app/e2e/sidebar-resize-handle.spec.ts
Normal file
27
packages/app/e2e/sidebar-resize-handle.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function expectBorderHighlight(page: Page, testID: string) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", "1px");
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
|
||||
});
|
||||
266
packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts
Normal file
266
packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
|
||||
// whenever the row was unmounted — a collapsed project section being the common case. It now
|
||||
// lives in a single always-mounted handler keyed on the active route selection.
|
||||
const PIN_SHORTCUT = "ControlOrMeta+Shift+P";
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
function pinnedSection(page: Page) {
|
||||
return page.getByTestId("sidebar-pinned-section");
|
||||
}
|
||||
|
||||
// Opens the workspace so it becomes the active route selection, which is what the shortcut acts on.
|
||||
async function openWorkspace(page: Page, workspaceId: string) {
|
||||
const row = workspaceRow(page, workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The project key is host-scoped and not exposed by the seed helper, so the header is addressed by
|
||||
// its display name, scoped to project rows so a workspace row can never match. Pressing the header
|
||||
// toggles the section, which unmounts every workspace row under it.
|
||||
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: project.projectDisplayName });
|
||||
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Status mode buckets workspaces by state rather than project, so the group holding this workspace
|
||||
// is discovered from the rows container it sits in rather than assumed.
|
||||
async function collapseStatusGroupContaining(page: Page, workspaceId: string): Promise<void> {
|
||||
const rows = page
|
||||
.locator('[data-testid^="sidebar-status-group-rows-"]')
|
||||
.filter({ has: workspaceRow(page, workspaceId) });
|
||||
await expect(rows).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
const rowsTestId = await rows.getAttribute("data-testid");
|
||||
const bucket = rowsTestId?.replace("sidebar-status-group-rows-", "");
|
||||
expect(bucket).toBeTruthy();
|
||||
|
||||
await page.getByTestId(`sidebar-status-group-${bucket}`).click();
|
||||
await expect(workspaceRow(page, workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
function readSessionMessage(
|
||||
message: string | Buffer,
|
||||
): { type?: unknown; requestId?: unknown } | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
if (envelope.type !== "session" || typeof envelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return envelope.message as { type?: unknown; requestId?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PIN_REJECTION_MESSAGE = "Pin rejected by test.";
|
||||
|
||||
interface PinRpcGate {
|
||||
/** Pin requests the client has sent so far. */
|
||||
sentCount(): number;
|
||||
}
|
||||
|
||||
// Proxies everything so the app boots against the real daemon, counting pin RPCs and optionally
|
||||
// rejecting the first `rejectFirst` of them. The count asserts how many pins one keypress actually
|
||||
// dispatched, which the rendered pin state cannot show — a toggle that fired twice lands back
|
||||
// where it started.
|
||||
async function installPinRpcGate(
|
||||
page: Page,
|
||||
options: { rejectFirst?: number } = {},
|
||||
): Promise<PinRpcGate> {
|
||||
const rejectFirst = options.rejectFirst ?? 0;
|
||||
let sent = 0;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = readSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "workspace.pin.set.request" &&
|
||||
typeof sessionMessage.requestId === "string"
|
||||
) {
|
||||
sent += 1;
|
||||
if (sent <= rejectFirst) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: sessionMessage.requestId,
|
||||
requestType: "workspace.pin.set.request",
|
||||
error: PIN_REJECTION_MESSAGE,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { sentCount: () => sent };
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
pinnedSection(page).getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
||||
// shadowed second handler is unobservable from outside by design.
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await switchToStatusGrouping(page);
|
||||
await collapseStatusGroupContaining(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
pinnedSection(page).getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an error toast when the host rejects the pin, and the next press succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-failure-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page, { rejectFirst: 1 });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(PIN_REJECTION_MESSAGE, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(pinnedSection(page)).toHaveCount(0);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
|
||||
// The failure must leave the action usable: the in-flight guard has to release the key so a
|
||||
// retry is not swallowed. Without that release the workspace is unpinnable for the session.
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "./helpers/sidebar";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
@@ -46,24 +47,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -96,21 +98,24 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: "test-owner/test-repo",
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -164,6 +169,30 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
|
||||
await expect(row).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Half-screen desktop layout", () => {
|
||||
@@ -190,6 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
170
packages/app/e2e/terminal-stuck-size.spec.ts
Normal file
170
packages/app/e2e/terminal-stuck-size.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||
import { getTerminalBufferText } from "./helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
/**
|
||||
* Regression: a terminal created while the window is unfocused must still claim its PTY size
|
||||
* once focus returns.
|
||||
*
|
||||
* The PTY is only ever resized by an explicit client claim (`terminal_input` resize). A freshly
|
||||
* mounted terminal starts its claim from terminal-pane's pane-focus reflow effect. Previously, if
|
||||
* that claim was emitted while the app was not actively visible, `handleTerminalResize` dropped
|
||||
* it without a retry, leaving the PTY at 80x24 while xterm rendered the real pane size.
|
||||
*
|
||||
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
||||
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
||||
* stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events)
|
||||
* instead of racing real OS focus.
|
||||
*
|
||||
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
||||
* daemon-side — clicking or typing in the pane would fire the focus-claim path and mask the bug.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through
|
||||
* `document.hasFocus()` plus the window focus/blur events.
|
||||
*
|
||||
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
||||
* calling bringToFront() leaves the first page at `hasFocus() === true`, `visibilityState
|
||||
* === "visible"`, and fires no focus/blur events at all — so there is no way to produce a real
|
||||
* blur from Playwright. This stubs the environment signal, not the app: the daemon, the
|
||||
* WebSocket, the terminal, and every code path under test stay real.
|
||||
*/
|
||||
async function setWindowFocused(page: Page, focused: boolean): Promise<void> {
|
||||
await page.evaluate((isFocused) => {
|
||||
Object.defineProperty(document, "hasFocus", {
|
||||
configurable: true,
|
||||
value: () => isFocused,
|
||||
});
|
||||
window.dispatchEvent(new Event(isFocused ? "focus" : "blur"));
|
||||
}, focused);
|
||||
}
|
||||
|
||||
interface RenderedTerminalSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSize | null> {
|
||||
return await page.evaluate(() => {
|
||||
const terminal = (window as Window & { __paseoTerminal?: { rows: number; cols: number } })
|
||||
.__paseoTerminal;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
||||
}
|
||||
|
||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||
const listed = await harness.client.listTerminals(harness.tempRepo.path, undefined, {
|
||||
workspaceId: harness.workspaceId,
|
||||
});
|
||||
return listed.terminals.map((terminal) => terminal.id);
|
||||
}
|
||||
|
||||
// These narrow instead of asserting: an `expect(...).toBeTruthy()` plus an early return would let
|
||||
// the test pass without ever reaching the assertions that prove the fix.
|
||||
function exactlyOne<T>(values: T[], what: string): T {
|
||||
const [value] = values;
|
||||
if (values.length !== 1 || value === undefined) {
|
||||
throw new Error(`Expected exactly one ${what}, got ${values.length}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTerminalSize(size: RenderedTerminalSize | null): RenderedTerminalSize {
|
||||
if (!size) {
|
||||
throw new Error("No xterm instance rendered on the page");
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null {
|
||||
const matches = [...bufferText.matchAll(/S\d+=(\d+) (\d+)=/g)];
|
||||
const match = matches.at(-1);
|
||||
return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-stuck-size" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
test("a terminal created while the window is blurred claims its size when focus returns", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||
await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Wait for the daemon to know about the first terminal rather than sleeping: if it were
|
||||
// missing from this snapshot it would be misread as "new" below.
|
||||
await createTerminalViaMenu(page);
|
||||
await expect(harness.terminalSurface(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
const countTerminals = async () => (await listTerminalIds(harness)).length;
|
||||
await expect.poll(countTerminals, { timeout: 15_000 }).toBe(1);
|
||||
const knownIds = new Set(await listTerminalIds(harness));
|
||||
|
||||
// The user clicks away...
|
||||
await setWindowFocused(page, false);
|
||||
|
||||
// ...opens another terminal while the window is unfocused...
|
||||
await createTerminalViaMenu(page);
|
||||
const findNewTerminalIds = async () =>
|
||||
(await listTerminalIds(harness)).filter((id) => !knownIds.has(id));
|
||||
await expect.poll(async () => (await findNewTerminalIds()).length, { timeout: 15_000 }).toBe(1);
|
||||
const newTerminalId = exactlyOne(await findNewTerminalIds(), "new terminal");
|
||||
|
||||
// Keep the app blurred through the complete mount-time refit ladder, which runs out to 2s.
|
||||
await page.waitForTimeout(2_500);
|
||||
|
||||
await harness.client.subscribeTerminal(newTerminalId);
|
||||
|
||||
// Confirm the PTY is still at its spawn size before focus returns.
|
||||
harness.client.sendTerminalInput(newTerminalId, {
|
||||
type: "input",
|
||||
data: 'echo "S0=$(stty size)="\n',
|
||||
});
|
||||
await expect
|
||||
.poll(async () => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.toMatch(/S0=24 80=/);
|
||||
|
||||
// ...and comes back.
|
||||
await setWindowFocused(page, true);
|
||||
|
||||
// __paseoTerminal points at the most recently mounted xterm — the new terminal.
|
||||
const rendered = requireTerminalSize(await readRenderedTerminalSize(page));
|
||||
// Sanity: the pane really rendered at a desktop size, not the PTY default.
|
||||
expect(rendered.cols).toBeGreaterThan(80);
|
||||
|
||||
// The PTY itself must agree. Ask it via the daemon, never via the page: focusing or
|
||||
// typing in the pane triggers the focus-claim path and would mask the bug.
|
||||
let probe = 1;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
harness.client.sendTerminalInput(newTerminalId, {
|
||||
type: "input",
|
||||
data: `echo "S${probe++}=$(stty size)="\n`,
|
||||
});
|
||||
return parseLatestSttySize(await getTerminalBufferText(page));
|
||||
},
|
||||
{ timeout: 15_000, intervals: [100, 250, 500] },
|
||||
)
|
||||
.toEqual({ rows: rendered.rows, cols: rendered.cols });
|
||||
});
|
||||
});
|
||||
238
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
238
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface ShimmerEvidence {
|
||||
animationDuration: string;
|
||||
endPx: number;
|
||||
label: string;
|
||||
renderedWidth: number;
|
||||
startPx: number;
|
||||
}
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let releaseSecondRequested = false;
|
||||
let pauseServerMessages = false;
|
||||
let secondRunningForwarded = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (!secondRunningMessage || secondRunningForwarded) {
|
||||
return;
|
||||
}
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningForwarded = true;
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseServerMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
if (toolCall.status === "running" || toolCall.status === "executing") {
|
||||
return;
|
||||
}
|
||||
if (toolCall.status === "completed") {
|
||||
// The mock tool completes inside the daemon's coalescing window, so the
|
||||
// browser naturally receives its authoritative completed state first.
|
||||
ws.send(message);
|
||||
resolveFirstCompleted();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (
|
||||
toolCall.callId === secondCallId &&
|
||||
(toolCall.status === "running" || toolCall.status === "executing")
|
||||
) {
|
||||
secondRunningMessage = message;
|
||||
pauseServerMessages = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
|
||||
// Keep the second real tool call inspectably active. Production providers
|
||||
// send this same status shape while their work remains in flight.
|
||||
secondRunningMessage = replaceToolCallStatus(message, "running");
|
||||
pauseServerMessages = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
if (secondRunningMessage) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
|
||||
return locator.evaluate((root) => {
|
||||
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
|
||||
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
|
||||
);
|
||||
if (!shimmer) {
|
||||
throw new Error("Expected a running shimmer inside the badge");
|
||||
}
|
||||
const style = getComputedStyle(shimmer);
|
||||
return {
|
||||
animationDuration: style.animationDuration,
|
||||
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
|
||||
label: shimmer.textContent ?? "",
|
||||
renderedWidth: shimmer.getBoundingClientRect().width,
|
||||
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("measures an overview heading that becomes loading after its idle mount", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-shimmer-",
|
||||
title: "Tool-call shimmer",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
|
||||
|
||||
await gate.waitForFirstCompleted();
|
||||
const group = page.getByTestId("tool-call-group");
|
||||
await expect(group).toBeVisible();
|
||||
const idleGroupHandle = await group.elementHandle();
|
||||
if (!idleGroupHandle) {
|
||||
throw new Error("Expected the idle tool-call group to be mounted");
|
||||
}
|
||||
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
|
||||
const sameGroupNode = await group.evaluate(
|
||||
(node, previous) => node === previous,
|
||||
idleGroupHandle,
|
||||
);
|
||||
|
||||
await group.click();
|
||||
const runningChild = page.getByTestId("tool-call-badge").last();
|
||||
await expect(runningChild).toBeVisible();
|
||||
const header = await readShimmerEvidence(group);
|
||||
const child = await readShimmerEvidence(runningChild);
|
||||
const evidence = { sameGroupNode, header, child };
|
||||
await testInfo.attach("tool-call-shimmer-evidence", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
expect(sameGroupNode).toBe(true);
|
||||
expect(child.endPx).toBeGreaterThan(0);
|
||||
expect(
|
||||
header.endPx,
|
||||
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
|
||||
).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
179
packages/app/e2e/viewed-agent-timelines.spec.ts
Normal file
179
packages/app/e2e/viewed-agent-timelines.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "./fixtures";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface ViewedTimelineScenario {
|
||||
client: SeedDaemonClient;
|
||||
workspaceId: string;
|
||||
firstAgentId: string;
|
||||
secondAgentId: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
||||
const createAgent = (title: string) =>
|
||||
workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat"),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function selectAgent(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function enableMoveTabShortcut(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
}
|
||||
|
||||
async function moveActiveTabRight(page: Page) {
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
}
|
||||
|
||||
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
|
||||
await scenario.client.sendAgentMessage(agentId, prompt);
|
||||
const finish = await scenario.client.waitForFinish(agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed while the first chat is hidden.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("two visible split chats both stay current", async ({ page }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await page.getByRole("button", { name: "Split pane right" }).click();
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await moveActiveTabRight(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "First viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
|
||||
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a visible chat catches up after reconnecting", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(recoveredMessage).toHaveCount(1);
|
||||
await expect(recoveredMessage).toBeVisible();
|
||||
} finally {
|
||||
gate.restore();
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
@@ -43,6 +44,49 @@ async function fetchActiveAgentTitle(
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
|
||||
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
await submitMessage(page, prompt);
|
||||
const agentId = await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
|
||||
|
||||
const overlayAppeared = page
|
||||
.getByTestId("agent-history-overlay")
|
||||
.waitFor({ state: "attached", timeout: 2_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
|
||||
expect(await overlayAppeared).toBe(false);
|
||||
} finally {
|
||||
timelineGate.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
51
packages/app/e2e/workspace-focus-mode.spec.ts
Normal file
51
packages/app/e2e/workspace-focus-mode.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
|
||||
async function pressFocusModeShortcut(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press(`${modifier}+Shift+F`);
|
||||
}
|
||||
|
||||
async function pressSettingsShortcut(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function blurActiveElement(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
}
|
||||
|
||||
test("focus mode only applies to the active workspace screen", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "focus-mode-boundary-" });
|
||||
await workspace.navigateTo();
|
||||
const exitFocusMode = page.getByRole("button", { name: "Exit focus mode" });
|
||||
const settingsButton = page.getByRole("button", { name: "Settings", exact: true });
|
||||
const settingsSidebar = page.getByRole("navigation", { name: "Settings" });
|
||||
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await blurActiveElement(page);
|
||||
await pressFocusModeShortcut(page);
|
||||
|
||||
await expect(exitFocusMode).toBeVisible();
|
||||
await expect(settingsButton).toHaveCount(0);
|
||||
const workspaceUrl = page.url();
|
||||
|
||||
await pressSettingsShortcut(page);
|
||||
|
||||
await expect(settingsSidebar).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(settingsSidebar).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await pressFocusModeShortcut(page);
|
||||
await page.goto(workspaceUrl);
|
||||
|
||||
await expect(exitFocusMode).toBeVisible();
|
||||
await exitFocusMode.click();
|
||||
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
|
||||
const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
|
||||
interface RestartDaemonClient {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -32,10 +35,53 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
type StartupPresentation = "splash" | "app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||
}
|
||||
}
|
||||
|
||||
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const trace: StartupPresentation[] = [];
|
||||
window.__paseoStartupPresentationTrace = trace;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const recordPresentation = () => {
|
||||
let presentation: StartupPresentation | null = null;
|
||||
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||
presentation = "splash";
|
||||
} else if (
|
||||
document.querySelector(
|
||||
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
presentation = "app";
|
||||
}
|
||||
if (presentation && trace.at(-1) !== presentation) {
|
||||
trace.push(presentation);
|
||||
}
|
||||
};
|
||||
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordPresentation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
@@ -114,64 +160,46 @@ async function expectWorkspaceLocation(
|
||||
});
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(
|
||||
sockets.map((ws) =>
|
||||
ws
|
||||
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
agentId: "notification-agent",
|
||||
};
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: target.serverId,
|
||||
label: "Notification Host",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.evaluate((data) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: { data: { ...data, reason: "finished" } },
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, target);
|
||||
|
||||
await expectAppRoute(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -294,7 +322,9 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
@@ -309,6 +339,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
desktopSettingsDelayMs: 250,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
@@ -316,14 +347,16 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
|
||||
await observeStartupPresentation(page);
|
||||
await daemonGate.drop();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
await waitForDesktopDaemonStartRequest(page);
|
||||
daemonGate.restore();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
await workspace.cleanup();
|
||||
@@ -429,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -443,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -36,7 +36,52 @@ async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<str
|
||||
return terminal.id;
|
||||
}
|
||||
|
||||
async function cleanupTerminal(
|
||||
workspace: SeededWorkspace,
|
||||
terminalId: string | null,
|
||||
): Promise<void> {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
async function readClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByTestId(
|
||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
||||
);
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(terminalId);
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -74,10 +119,7 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { restartTestDaemon } from "./helpers/daemon-restart";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -11,11 +12,12 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore after daemon restart", () => {
|
||||
const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
let daemon: IsolatedHostDaemon;
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
@@ -25,8 +27,9 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
daemon = await startIsolatedHostDaemon(serverId);
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
tempRepo = await createTempGitRepo("wt-restart-");
|
||||
});
|
||||
|
||||
@@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedBrowser(page: Page) {
|
||||
const nowIso = new Date().toISOString();
|
||||
await page.addInitScript(
|
||||
({ host, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{
|
||||
host: buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
||||
// worktree workspace is displayed under the same name. These are the values
|
||||
// the History table cells must show after restore — never "main".
|
||||
@@ -76,14 +99,16 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
||||
// Restart this spec's daemon on the same home and port so it rebuilds all
|
||||
// workspace/agent links from persisted state without replacing the shared
|
||||
// Playwright daemon owned by global setup.
|
||||
await client.close().catch(() => undefined);
|
||||
await worktreeClient.close().catch(() => undefined);
|
||||
await restartTestDaemon();
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
await daemon.restart();
|
||||
client = await connectSeedClient({ port: daemon.port });
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
|
||||
|
||||
await seedBrowser(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
|
||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
async function createArchivedMissingWorktree(
|
||||
prefix: string,
|
||||
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||
) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
}
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
if (options.keepAgentsArchived) {
|
||||
for (const archivedAgent of agents) {
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||
.not.toBeNull();
|
||||
}
|
||||
} else {
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
}
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
return { agent, agents, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
});
|
||||
const [firstAgent, secondAgent] = agents;
|
||||
if (!firstAgent || !secondAgent) {
|
||||
throw new Error("Expected two archived agents");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
@@ -249,7 +318,7 @@ test.describe("Worktree restore", () => {
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
"The source repository needed to restore this worktree no longer exists.",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "production",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"resourceClass": "large",
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.109",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -32,6 +32,11 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -46,6 +51,7 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
|
||||
@@ -11,6 +11,51 @@ const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||
def paseoAbiVersionCodes = [
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
"x86": 3,
|
||||
"x86_64": 4,
|
||||
]
|
||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||
.toString()
|
||||
.split(",")
|
||||
.collect { it.trim() }
|
||||
.findAll { !it.isEmpty() }
|
||||
|
||||
if (paseoArchitectures.size() == 1) {
|
||||
def paseoAbi = paseoArchitectures[0]
|
||||
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
|
||||
if (paseoAbiVersionCode == null) {
|
||||
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
|
||||
}
|
||||
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
|
||||
}
|
||||
`;
|
||||
|
||||
function configureFdroidAppBuildGradle(contents) {
|
||||
let configuredContents = contents;
|
||||
|
||||
if (!configuredContents.includes("dependenciesInfo {")) {
|
||||
const androidBlock = "android {";
|
||||
if (!configuredContents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
configuredContents = configuredContents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
|
||||
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
|
||||
}
|
||||
|
||||
return configuredContents;
|
||||
}
|
||||
|
||||
function withFdroidAutolinking(config) {
|
||||
config = withDangerousMod(config, [
|
||||
"android",
|
||||
@@ -65,19 +110,7 @@ function withFdroidAutolinking(config) {
|
||||
});
|
||||
|
||||
return withAppBuildGradle(config, (modConfig) => {
|
||||
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const androidBlock = "android {";
|
||||
if (!modConfig.modResults.contents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
addProjectMethodEmptyText,
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("hides every mutating method when the host lacks stable project identity", () => {
|
||||
const outdatedHost = { ...HOST, canAddProject: false };
|
||||
|
||||
expect(buildAddProjectMethods(outdatedHost)).toEqual([]);
|
||||
expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project.");
|
||||
});
|
||||
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
|
||||
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
|
||||
}
|
||||
|
||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||
if (!host.canAddProject) return [];
|
||||
const options: AddProjectMethodOption[] = [];
|
||||
if (host.canAddProject) {
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
if (host.canBrowse) {
|
||||
options.push({
|
||||
id: "browse",
|
||||
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
|
||||
return options;
|
||||
}
|
||||
|
||||
export function addProjectMethodEmptyText(host: AddProjectHost | null): string {
|
||||
return host?.canAddProject === false
|
||||
? "Update the host to use Add Project."
|
||||
: "No matching options";
|
||||
}
|
||||
|
||||
function githubMethodDescription(host: AddProjectHost): string {
|
||||
if (!host.canCloneGithubRepositories) {
|
||||
return "Update this host to clone GitHub repositories";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
@@ -126,10 +126,15 @@ function createDriverHarness(input?: {
|
||||
measurementState,
|
||||
nearBottom: input?.isNearBottom ?? true,
|
||||
};
|
||||
const scrollToBottom = vi.fn(() => {
|
||||
const scrollAttempts: boolean[] = [];
|
||||
let scrollToBottomBehavior = () => {
|
||||
context.nearBottom = true;
|
||||
context.measurementState.offsetY = 720;
|
||||
});
|
||||
};
|
||||
const scrollToBottom = (animated: boolean) => {
|
||||
scrollAttempts.push(animated);
|
||||
scrollToBottomBehavior();
|
||||
};
|
||||
const modeChanges: BottomAnchorMode[] = [];
|
||||
const driver = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => context.agentId,
|
||||
@@ -150,7 +155,10 @@ function createDriverHarness(input?: {
|
||||
context,
|
||||
driver,
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
scrollAttempts,
|
||||
setScrollToBottomBehavior(next: () => void) {
|
||||
scrollToBottomBehavior = next;
|
||||
},
|
||||
modeChanges,
|
||||
};
|
||||
}
|
||||
@@ -210,7 +218,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: "waiting_for_history_readiness",
|
||||
@@ -229,7 +237,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
@@ -237,6 +245,97 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "route:agent-1:initial-entry",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a blocked route anchor when layout moves after drag release", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a user scroll away supersede a blocked route anchor", () => {
|
||||
const harness = createDriverHarness({ authoritativeReady: false });
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
});
|
||||
harness.driver.endUserScroll({ isNearBottom: false });
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "detached",
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses sticky maintenance while detached", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
@@ -254,7 +353,74 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 1,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.endUserScroll({ isNearBottom: false });
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
});
|
||||
|
||||
harness.driver.beginUserScroll();
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
});
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: -48,
|
||||
});
|
||||
harness.driver.endUserScroll({ isNearBottom: true });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||
@@ -271,7 +437,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
expect(harness.modeChanges).toContain("detached");
|
||||
expect(harness.modeChanges).toContain("sticky-bottom");
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
@@ -292,7 +458,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
||||
@@ -313,7 +479,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_measurable_viewport",
|
||||
pendingRequest: {
|
||||
@@ -326,7 +492,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -338,7 +504,7 @@ describe("bottom anchor controller driver", () => {
|
||||
},
|
||||
isNearBottom: false,
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -348,16 +514,16 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -375,7 +541,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 13476;
|
||||
});
|
||||
|
||||
@@ -386,7 +552,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.measurementState.contentHeight = 14804;
|
||||
harness.context.nearBottom = false;
|
||||
@@ -409,7 +575,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_post_layout_verification",
|
||||
pendingRequest: {
|
||||
@@ -435,7 +601,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = Math.max(
|
||||
0,
|
||||
harness.context.measurementState.contentHeight -
|
||||
@@ -471,7 +637,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
|
||||
reason: "resume",
|
||||
});
|
||||
@@ -480,7 +646,7 @@ describe("bottom anchor controller driver", () => {
|
||||
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 720;
|
||||
});
|
||||
|
||||
@@ -492,7 +658,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -523,7 +689,7 @@ describe("bottom anchor controller driver", () => {
|
||||
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 900;
|
||||
});
|
||||
|
||||
@@ -533,7 +699,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -577,7 +743,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: null,
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -588,7 +754,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentHeight: 1348,
|
||||
});
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
@@ -625,14 +791,14 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
|
||||
@@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver {
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
endUserScroll: (params: { isNearBottom: boolean }) => void;
|
||||
detachByUser: () => void;
|
||||
handleViewportMetricsChange: (params: {
|
||||
previousViewportWidth: number;
|
||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
let isUserScrollActive = false;
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
@@ -411,10 +414,14 @@ function createBottomAnchorControllerDriver(
|
||||
| "viewport_change"
|
||||
| "content_size_change"
|
||||
| "scroll_near_bottom_change"
|
||||
| "user_scroll_end"
|
||||
| "history_readiness_change"
|
||||
| "manual_reevaluate"
|
||||
| "retry_scroll",
|
||||
) => {
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (attemptHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -481,6 +488,7 @@ function createBottomAnchorControllerDriver(
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
@@ -497,6 +505,38 @@ function createBottomAnchorControllerDriver(
|
||||
requestLocalAnchor(request) {
|
||||
createRequest(request);
|
||||
},
|
||||
beginUserScroll() {
|
||||
isUserScrollActive = true;
|
||||
cancelPendingAttempt();
|
||||
},
|
||||
endUserScroll(params) {
|
||||
isUserScrollActive = false;
|
||||
if (params.isNearBottom) {
|
||||
if (mode === "detached") {
|
||||
setModeInternal("sticky-bottom");
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
if (pendingRequest) {
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!input.isNearBottom() ||
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
||||
) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementVerified();
|
||||
return;
|
||||
}
|
||||
if (mode === "sticky-bottom") {
|
||||
this.detachByUser();
|
||||
}
|
||||
},
|
||||
detachByUser() {
|
||||
if (mode === "detached") {
|
||||
return;
|
||||
@@ -511,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
@@ -529,6 +572,9 @@ function createBottomAnchorControllerDriver(
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
@@ -558,6 +604,9 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
@@ -571,6 +620,9 @@ function createBottomAnchorControllerDriver(
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
@@ -737,6 +789,12 @@ export function useBottomAnchorController(input: {
|
||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||
driverRef.current?.requestLocalAnchor(request);
|
||||
},
|
||||
beginUserScroll() {
|
||||
driverRef.current?.beginUserScroll();
|
||||
},
|
||||
endUserScroll(params: { isNearBottom: boolean }) {
|
||||
driverRef.current?.endUserScroll(params);
|
||||
},
|
||||
detachByUser() {
|
||||
driverRef.current?.detachByUser();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const visibleHistoryStart = {
|
||||
distanceFromHistoryStart: 0,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "epoch-1:20",
|
||||
};
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("loads once for each authoritative history cursor", () => {
|
||||
const initial = createHistoryStartPaginationState();
|
||||
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
const nextPage = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows the same revision again after the user leaves the history edge", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const away = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 200,
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
|
||||
|
||||
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
|
||||
});
|
||||
|
||||
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(first.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal file
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
requestedProgressKey: string | null;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { requestedProgressKey: null };
|
||||
}
|
||||
|
||||
export function rearmHistoryStartPagination(
|
||||
_state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return createHistoryStartPaginationState();
|
||||
}
|
||||
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: {
|
||||
distanceFromHistoryStart: number;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
},
|
||||
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
|
||||
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
return { state: createHistoryStartPaginationState(), shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.requestedProgressKey === input.progressKey) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
state: { requestedProgressKey: input.progressKey },
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
type ScrollKeyboardDismissEvent,
|
||||
type ScrollKeyboardDismissGesture,
|
||||
} from "./model";
|
||||
|
||||
interface Point {
|
||||
ts: number;
|
||||
y: number;
|
||||
nativeTs?: number | null;
|
||||
}
|
||||
|
||||
function event(point: Point): ScrollKeyboardDismissEvent {
|
||||
return {
|
||||
timeStamp: point.ts,
|
||||
nativeEvent: {
|
||||
contentOffset: { y: point.y },
|
||||
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
|
||||
return points.reduce(
|
||||
(gesture, point) => recordScroll(gesture, event(point)),
|
||||
beginDrag(event(start)),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
|
||||
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
|
||||
}
|
||||
|
||||
describe("scroll keyboard dismissal", () => {
|
||||
it("keeps the keyboard up for a slow read-scroll", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 8 },
|
||||
{ ts: 1080, y: 16 },
|
||||
{ ts: 1120, y: 24 },
|
||||
],
|
||||
{ ts: 1160, y: 32 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses for an upward flick", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a fast-but-decelerating drag below the release threshold", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1100, y: 500 },
|
||||
{ ts: 1200, y: 590 },
|
||||
{ ts: 1270, y: 598 },
|
||||
],
|
||||
{ ts: 1300, y: 600 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the whole drag when the gesture is too short to sample", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("steps back a sample when release lands immediately after one", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 120 },
|
||||
{ ts: 1075, y: 225 },
|
||||
],
|
||||
{ ts: 1080, y: 240 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when release has no measurable time span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scroll events that arrive before the minimum sample span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 5000, nativeTs: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 5001, nativeTs: 1200, y: 40 },
|
||||
{ ts: 5002, nativeTs: 1400, y: 80 },
|
||||
],
|
||||
{ ts: 5003, nativeTs: 1600, y: 120 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to synthetic event time when native time is absent", () => {
|
||||
expect(
|
||||
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
|
||||
ts: 1080,
|
||||
nativeTs: null,
|
||||
y: 240,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll and release events without a matching drag", () => {
|
||||
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
|
||||
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to idle after release", () => {
|
||||
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
|
||||
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
});
|
||||
});
|
||||
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pure state machine for the chat history's flick-to-dismiss gesture.
|
||||
*
|
||||
* Native keyboardDismissMode cannot express this interaction: "on-drag"
|
||||
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
|
||||
* inverted list. We therefore classify the list's existing scroll gesture at
|
||||
* release instead of introducing a competing pan recognizer.
|
||||
*/
|
||||
|
||||
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
|
||||
const RELEASE_SAMPLE_MIN_MS = 30;
|
||||
|
||||
/**
|
||||
* The subset of a native scroll event used by the classifier. React Native's
|
||||
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
|
||||
*/
|
||||
export interface ScrollKeyboardDismissEvent {
|
||||
timeStamp: number;
|
||||
nativeEvent: {
|
||||
contentOffset: { y: number };
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DragSamples {
|
||||
startTs: number;
|
||||
startY: number;
|
||||
sampleTs: number;
|
||||
sampleY: number;
|
||||
previousSampleTs: number;
|
||||
previousSampleY: number;
|
||||
}
|
||||
|
||||
export type ScrollKeyboardDismissGesture =
|
||||
| { phase: "idle" }
|
||||
| { phase: "dragging"; samples: DragSamples };
|
||||
|
||||
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
|
||||
const nativeTimestamp = event.nativeEvent.timestamp;
|
||||
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
|
||||
return nativeTimestamp;
|
||||
}
|
||||
return event.timeStamp;
|
||||
}
|
||||
|
||||
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: timestamp,
|
||||
startY: offsetY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: offsetY,
|
||||
previousSampleTs: 0,
|
||||
previousSampleY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordScroll(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): ScrollKeyboardDismissGesture {
|
||||
if (gesture.phase === "idle") {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const { samples } = gesture;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: samples.startTs,
|
||||
startY: samples.startY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: event.nativeEvent.contentOffset.y,
|
||||
previousSampleTs: samples.sampleTs,
|
||||
previousSampleY: samples.sampleY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseDrag(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
|
||||
if (gesture.phase === "idle") {
|
||||
return { gesture, shouldDismiss: false };
|
||||
}
|
||||
|
||||
const releaseTs = resolveEventTimeMs(event);
|
||||
const releaseY = event.nativeEvent.contentOffset.y;
|
||||
const { samples } = gesture;
|
||||
|
||||
// The release event carries the gesture's true endpoint. The final onScroll
|
||||
// event can still be stale when a short flick lands.
|
||||
let spanStartTs = samples.startTs;
|
||||
let spanStartY = samples.startY;
|
||||
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
|
||||
spanStartTs = samples.sampleTs;
|
||||
spanStartY = samples.sampleY;
|
||||
} else if (samples.previousSampleTs > 0) {
|
||||
spanStartTs = samples.previousSampleTs;
|
||||
spanStartY = samples.previousSampleY;
|
||||
}
|
||||
|
||||
const spanDurationMs = releaseTs - spanStartTs;
|
||||
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
|
||||
|
||||
return {
|
||||
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
TextInput,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
} from "./model";
|
||||
|
||||
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
|
||||
|
||||
/**
|
||||
* Owns the chat history's flick-to-dismiss behavior. The native stream only
|
||||
* forwards the FlatList scroll lifecycle; removing this hook and those three
|
||||
* calls removes the feature completely.
|
||||
*/
|
||||
export function useScrollKeyboardDismiss() {
|
||||
const { shift } = useKeyboardShift();
|
||||
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
|
||||
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = beginDrag(event);
|
||||
});
|
||||
|
||||
const onScroll = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = recordScroll(gestureRef.current, event);
|
||||
});
|
||||
|
||||
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
|
||||
const release = releaseDrag(gestureRef.current, event);
|
||||
gestureRef.current = release.gesture;
|
||||
|
||||
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
|
||||
// second calculation on JS, this prevents a hardware keyboard's focused
|
||||
// composer from being blurred when no software keyboard occupies space.
|
||||
if (!release.shouldDismiss || shift.value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep blur and dismiss paired: this exact sequence was validated on a
|
||||
// physical Android device to clear both input focus and the IME inset.
|
||||
const focusedInput = TextInput.State.currentlyFocusedInput();
|
||||
if (focusedInput) {
|
||||
TextInput.State.blurTextInput(focusedInput);
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
});
|
||||
|
||||
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user