mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d321899c | ||
|
|
ef418d5292 | ||
|
|
d0f7d93fc4 | ||
|
|
32d9147499 | ||
|
|
ad0e754cc1 | ||
|
|
d152b4f055 | ||
|
|
c92e4e46d2 | ||
|
|
691e2ee7e6 | ||
|
|
6b84c8963d | ||
|
|
0c3d15ffb9 | ||
|
|
480b18c500 | ||
|
|
f9e18424e9 | ||
|
|
52eb307e8c | ||
|
|
50a12e2140 | ||
|
|
d118e2349e | ||
|
|
446dd9cfe6 | ||
|
|
5b2f3fd830 | ||
|
|
2dd8c27573 | ||
|
|
dc92863c35 | ||
|
|
ba9ed8b995 | ||
|
|
2f381a76ed | ||
|
|
cda3d1d46c | ||
|
|
731c570b25 | ||
|
|
177153e5e8 | ||
|
|
9c88850de0 | ||
|
|
ce4b0d54c7 | ||
|
|
9b25f8fc36 | ||
|
|
26e6d97e81 | ||
|
|
80102753e1 | ||
|
|
75b8ae640c | ||
|
|
e521e1f79d | ||
|
|
a62c7471d2 | ||
|
|
3edf812578 | ||
|
|
dfc8223bcc | ||
|
|
083cd34db4 | ||
|
|
9abc0217e9 | ||
|
|
576cc0b668 | ||
|
|
e21ef2fc9b | ||
|
|
c8b61f7ddc | ||
|
|
9c5974735e | ||
|
|
f65f82bac9 | ||
|
|
9a35f35bab | ||
|
|
8cc7e8951c | ||
|
|
38a41a71ff | ||
|
|
ce6393a933 | ||
|
|
339a0b6b43 | ||
|
|
1f7b4eb10f | ||
|
|
3560fb58bb | ||
|
|
4140e640fe | ||
|
|
434b28fc8e | ||
|
|
1f8f8e4036 | ||
|
|
b133285f6d | ||
|
|
4d5889ff38 | ||
|
|
de57f301f9 | ||
|
|
95c773ce6d | ||
|
|
c6680b3001 | ||
|
|
484e453401 | ||
|
|
4b867c2f41 | ||
|
|
368ce0339a | ||
|
|
8e63274ce6 | ||
|
|
d80c524e0e | ||
|
|
89efb007f9 | ||
|
|
407b4b68b0 | ||
|
|
637109eab0 | ||
|
|
3a0161d051 | ||
|
|
027b3839fc | ||
|
|
c3589bdc84 | ||
|
|
a5e6cc2d2b | ||
|
|
713c90ea42 | ||
|
|
e51fdada51 | ||
|
|
dd98ae9359 | ||
|
|
274a7fd217 | ||
|
|
a582fccef0 | ||
|
|
019bf65790 | ||
|
|
9218f70df3 | ||
|
|
fd05bf8bff | ||
|
|
9e3d8f0ec5 | ||
|
|
43b9123c5c | ||
|
|
b6afc66131 | ||
|
|
7993dc02dc | ||
|
|
6601db2673 | ||
|
|
e4badd95f4 | ||
|
|
02fc354686 | ||
|
|
aef54d5180 | ||
|
|
3c5bfd6e3d | ||
|
|
f422dabf1e | ||
|
|
2706d5e934 | ||
|
|
6b210df471 | ||
|
|
9a39ba4be0 | ||
|
|
fc86ce2a5f | ||
|
|
6edf19c7c6 | ||
|
|
80c319dc49 | ||
|
|
77c8c91ab5 | ||
|
|
6503245493 | ||
|
|
184902dd0d | ||
|
|
42cdabdf57 | ||
|
|
247983ee87 | ||
|
|
7b9cc3d423 | ||
|
|
8ee3c6ae1d | ||
|
|
244c40a62f | ||
|
|
3fe63d5a2c | ||
|
|
1582028902 | ||
|
|
7e7cc1ad56 | ||
|
|
27ddfb1ea7 | ||
|
|
807cce6ad9 | ||
|
|
73b28a7f60 | ||
|
|
8dad423635 | ||
|
|
abaa29e73c | ||
|
|
ae2a301cb6 | ||
|
|
006d57d546 | ||
|
|
a201e5a853 | ||
|
|
c402db8b1c | ||
|
|
6a03873d81 | ||
|
|
39db3ea5ff | ||
|
|
79bf5ac3c7 | ||
|
|
051507717c | ||
|
|
f3cb19529c | ||
|
|
cea97c83b3 | ||
|
|
3e2faab7f8 |
@@ -51,7 +51,7 @@ jobs:
|
|||||||
release create "$RELEASE_TAG"
|
release create "$RELEASE_TAG"
|
||||||
--repo "${{ github.repository }}"
|
--repo "${{ github.repository }}"
|
||||||
--title "Paseo $RELEASE_TAG"
|
--title "Paseo $RELEASE_TAG"
|
||||||
--generate-notes
|
--notes ""
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
|||||||
+39
-20
@@ -15,14 +15,30 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: npx biome format .
|
run: npx oxfmt --check .
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -31,8 +47,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -58,8 +74,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Fetch origin/main (worktree tests)
|
- name: Fetch origin/main (worktree tests)
|
||||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
@@ -88,8 +104,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -126,12 +142,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Build highlight dependency
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
@@ -145,8 +164,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -188,8 +207,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -207,8 +226,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -222,6 +241,6 @@ jobs:
|
|||||||
- name: Run CLI tests
|
- name: Run CLI tests
|
||||||
run: npm run test --workspace=@getpaseo/cli
|
run: npm run test --workspace=@getpaseo/cli
|
||||||
env:
|
env:
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||||
PASEO_DICTATION_ENABLED: '0'
|
PASEO_DICTATION_ENABLED: "0"
|
||||||
PASEO_VOICE_MODE_ENABLED: '0'
|
PASEO_VOICE_MODE_ENABLED: "0"
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: Deploy App
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
- '!v*-beta.*'
|
- "!v*-beta.*"
|
||||||
- 'app-v*'
|
- "app-v*"
|
||||||
- '!app-v*-beta.*'
|
- "!app-v*-beta.*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,10 +18,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: "https://npm.pkg.github.com"
|
||||||
scope: '@boudra'
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install --workspace=@getpaseo/app --include-workspace-root
|
run: npm install --workspace=@getpaseo/app --include-workspace-root
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'packages/relay/**'
|
- "packages/relay/**"
|
||||||
- '.github/workflows/deploy-relay.yml'
|
- ".github/workflows/deploy-relay.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,8 +17,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||||
@@ -30,4 +30,3 @@ jobs:
|
|||||||
run: cd packages/relay && npx wrangler deploy
|
run: cd packages/relay && npx wrangler deploy
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'packages/website/**'
|
- "CHANGELOG.md"
|
||||||
- 'package.json'
|
- "packages/website/**"
|
||||||
- 'package-lock.json'
|
- "package.json"
|
||||||
- 'patches/**'
|
- "package-lock.json"
|
||||||
- '.github/workflows/deploy-website.yml'
|
- "patches/**"
|
||||||
|
- ".github/workflows/deploy-website.yml"
|
||||||
release:
|
release:
|
||||||
types: [published, edited]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -23,8 +24,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ name: Desktop Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
- 'desktop-v*'
|
- "desktop-v*"
|
||||||
- 'desktop-macos-v*'
|
- "desktop-macos-v*"
|
||||||
- 'desktop-linux-v*'
|
- "desktop-linux-v*"
|
||||||
- 'desktop-windows-v*'
|
- "desktop-windows-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
description: "Existing tag to build (e.g. v0.1.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
platform:
|
platform:
|
||||||
description: 'Optional desktop platform to build.'
|
description: "Optional desktop platform to build."
|
||||||
required: false
|
required: false
|
||||||
default: 'all'
|
default: "all"
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- all
|
- all
|
||||||
@@ -31,8 +31,8 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
DESKTOP_WORKSPACE: "@getpaseo/desktop"
|
||||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
gh release create "$RELEASE_TAG" \
|
gh release create "$RELEASE_TAG" \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--generate-notes \
|
--notes "" \
|
||||||
$prerelease_flag || {
|
$prerelease_flag || {
|
||||||
echo "Release creation raced with another workflow; continuing."
|
echo "Release creation raced with another workflow; continuing."
|
||||||
}
|
}
|
||||||
@@ -100,11 +100,11 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: "https://npm.pkg.github.com"
|
||||||
scope: '@boudra'
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -278,11 +278,11 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: "https://npm.pkg.github.com"
|
||||||
scope: '@boudra'
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -346,11 +346,11 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: "https://npm.pkg.github.com"
|
||||||
scope: '@boudra'
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -25,8 +25,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- uses: cachix/install-nix-action@v31
|
- uses: cachix/install-nix-action@v31
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,27 +4,27 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'nix/**'
|
- "nix/**"
|
||||||
- 'flake.nix'
|
- "flake.nix"
|
||||||
- 'flake.lock'
|
- "flake.lock"
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
- 'packages/highlight/**'
|
- "packages/highlight/**"
|
||||||
- 'packages/server/**'
|
- "packages/server/**"
|
||||||
- 'packages/relay/**'
|
- "packages/relay/**"
|
||||||
- 'packages/cli/**'
|
- "packages/cli/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'nix/**'
|
- "nix/**"
|
||||||
- 'flake.nix'
|
- "flake.nix"
|
||||||
- 'flake.lock'
|
- "flake.lock"
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
- 'packages/highlight/**'
|
- "packages/highlight/**"
|
||||||
- 'packages/server/**'
|
- "packages/server/**"
|
||||||
- 'packages/relay/**'
|
- "packages/relay/**"
|
||||||
- 'packages/cli/**'
|
- "packages/cli/**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create_if_missing="false"
|
create_if_missing="false"
|
||||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
|
||||||
|
create_if_missing="true"
|
||||||
|
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||||
create_if_missing="true"
|
create_if_missing="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'packages/server/**'
|
- "packages/server/**"
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
- '.github/workflows/server-ci.yml'
|
- ".github/workflows/server-ci.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'packages/server/**'
|
- "packages/server/**"
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
- '.github/workflows/server-ci.yml'
|
- ".github/workflows/server-ci.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -27,8 +27,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Fetch origin/main (worktree tests)
|
- name: Fetch origin/main (worktree tests)
|
||||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": false,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"ignorePatterns": ["*.lock"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "error",
|
||||||
|
"perf": "error"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
|
||||||
|
"no-await-in-loop": "off",
|
||||||
|
|
||||||
|
"no-unused-expressions": "error",
|
||||||
|
"no-useless-catch": "error",
|
||||||
|
"preserve-caught-error": "error",
|
||||||
|
"require-await": "off",
|
||||||
|
"no-async-promise-executor": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-empty-pattern": "error",
|
||||||
|
"no-self-assign": "error",
|
||||||
|
"no-shadow": "error",
|
||||||
|
"unicorn/consistent-function-scoping": "off",
|
||||||
|
"unicorn/no-array-sort": "off",
|
||||||
|
|
||||||
|
"unicorn/no-useless-spread": "error",
|
||||||
|
"unicorn/no-useless-fallback-in-spread": "error",
|
||||||
|
"unicorn/no-new-array": "error",
|
||||||
|
"unicorn/no-empty-file": "error",
|
||||||
|
|
||||||
|
"promise/always-return": "error",
|
||||||
|
"promise/no-multiple-resolved": "error",
|
||||||
|
|
||||||
|
"react/no-array-index-key": "error",
|
||||||
|
"react/jsx-no-useless-fragment": "error",
|
||||||
|
"react/jsx-no-constructed-context-values": "error",
|
||||||
|
"react/no-unescaped-entities": "error",
|
||||||
|
"react/button-has-type": "error",
|
||||||
|
"react/jsx-max-depth": ["error", { "max": 6 }],
|
||||||
|
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
|
||||||
|
"react-perf/jsx-no-new-array-as-prop": "error",
|
||||||
|
"react-perf/jsx-no-new-function-as-prop": "error",
|
||||||
|
"react-perf/jsx-no-new-object-as-prop": "error",
|
||||||
|
"react-perf/jsx-no-jsx-as-prop": "error",
|
||||||
|
|
||||||
|
"oxc/no-map-spread": "error",
|
||||||
|
"oxc/no-async-endpoint-handlers": "error",
|
||||||
|
"oxc/only-used-in-recursion": "error",
|
||||||
|
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
|
"typescript/prefer-as-const": "error",
|
||||||
|
"typescript/no-this-alias": "error",
|
||||||
|
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||||
|
|
||||||
|
"import/no-unassigned-import": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"**/*.css",
|
||||||
|
"**/expo-router/entry",
|
||||||
|
"**/event-target-polyfill",
|
||||||
|
"**/dotenv/config",
|
||||||
|
"**/react-native",
|
||||||
|
"**/@/styles/unistyles",
|
||||||
|
"**/src/styles/unistyles",
|
||||||
|
"**/@/test/window-local-storage"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"no-nested-ternary": "error",
|
||||||
|
"no-unneeded-ternary": "error",
|
||||||
|
|
||||||
|
"complexity": ["error", { "max": 20 }],
|
||||||
|
"max-depth": ["error", { "max": 4 }],
|
||||||
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/e2e/fixtures.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-empty-pattern": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+217
-3
@@ -1,26 +1,116 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.60-beta.1 - 2026-04-20
|
## 0.1.63-beta.1 - 2026-04-24
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Archiving a worktree is instant instead of waiting for the backend to confirm.
|
||||||
|
- Terminal sessions recover cleanly after rendering hiccups.
|
||||||
|
- Agent timelines and git diff lists no longer jump around while loading or streaming.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
|
||||||
|
|
||||||
|
## 0.1.62 - 2026-04-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
|
||||||
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pull request status resolves correctly for PRs opened from forks.
|
||||||
|
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
|
||||||
|
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
|
||||||
|
|
||||||
|
## 0.1.61 - 2026-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
|
||||||
|
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Pi loads your installed extensions on startup so their models show up in the model picker.
|
||||||
|
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
|
||||||
|
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
|
||||||
|
|
||||||
|
## 0.1.60 - 2026-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
|
||||||
|
- Launch scripts and services for a worktree directly from the workspace header.
|
||||||
|
- New Setup tab in every workspace showing setup, teardown, and script progress live.
|
||||||
|
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
|
||||||
|
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
|
||||||
|
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
|
||||||
|
- Pull request pane in the workspace sidebar.
|
||||||
|
- Redesigned Settings screen with modular section navigation.
|
||||||
|
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
|
||||||
|
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
|
||||||
|
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
|
||||||
|
- New-workspace picker ranks branches by recency with fast search.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
|
||||||
|
- Agent streams render more smoothly during heavy tool output.
|
||||||
|
- App startup routes through a stable connection and lands on the right screen without flicker.
|
||||||
|
- Provider refresh is reliable and no longer stalls on transient failures.
|
||||||
|
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
|
||||||
|
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
|
||||||
|
- In-app notifications route to whichever surface you're actually looking at.
|
||||||
|
- Keyboard shortcuts keep working while Settings is open.
|
||||||
|
- Escape reliably interrupts the active agent.
|
||||||
|
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
|
||||||
|
- `paseo ls` defaults to active agents; pass `-a` to include archived.
|
||||||
|
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Composer textarea shrinks back down after sending on web.
|
||||||
|
- New workspace drafts clear after submit instead of sticking around.
|
||||||
|
- Replacing a running agent cleans up the previous one without leaving it behind.
|
||||||
|
- Agent notifications no longer get swallowed by a backgrounded focused client.
|
||||||
|
- Removed workspace folders disappear from the workspace list again.
|
||||||
|
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
|
||||||
|
- Workspace tab focus is preserved across page refreshes.
|
||||||
|
- Settings screen no longer pushes its header down with extra spacing.
|
||||||
|
- Branch switcher title no longer overflows on narrow rows.
|
||||||
|
- iOS image picker no longer leaves the screen unresponsive after cancelling.
|
||||||
|
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
|
||||||
|
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
|
||||||
|
- Tool call blocks expand correctly on mobile while an agent is still streaming.
|
||||||
|
- Timeline no longer stutters when catch-up and projected ranges overlap.
|
||||||
|
- Codex no longer flashes idle when a replacement turn is in progress.
|
||||||
|
- Branch state recovers correctly when a rebase is in progress.
|
||||||
|
- Workspace hover card no longer clips near screen edges.
|
||||||
|
|
||||||
## 0.1.59 - 2026-04-16
|
## 0.1.59 - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
||||||
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
||||||
|
|
||||||
## 0.1.58 - 2026-04-16
|
## 0.1.58 - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
|
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
|
||||||
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
||||||
- `disallowedTools` option in provider config to block specific tools from an agent.
|
- `disallowedTools` option in provider config to block specific tools from an agent.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
||||||
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
|
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
@@ -30,6 +120,7 @@
|
|||||||
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
||||||
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
||||||
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
||||||
@@ -43,12 +134,14 @@
|
|||||||
## 0.1.56 - 2026-04-14
|
## 0.1.56 - 2026-04-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
||||||
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
||||||
|
|
||||||
## 0.1.55 - 2026-04-14
|
## 0.1.55 - 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
||||||
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
||||||
- Choose provider and model when creating scheduled agents.
|
- Choose provider and model when creating scheduled agents.
|
||||||
@@ -56,12 +149,14 @@
|
|||||||
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
||||||
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
||||||
- iPad and tablet layouts work correctly across all screen sizes.
|
- iPad and tablet layouts work correctly across all screen sizes.
|
||||||
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
||||||
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
||||||
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
||||||
@@ -71,13 +166,16 @@
|
|||||||
## 0.1.54 - 2026-04-12
|
## 0.1.54 - 2026-04-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
||||||
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
||||||
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
||||||
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
||||||
@@ -85,6 +183,7 @@
|
|||||||
## 0.1.53 - 2026-04-12
|
## 0.1.53 - 2026-04-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
||||||
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
||||||
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
||||||
@@ -93,6 +192,7 @@
|
|||||||
- Keyboard shortcut to cycle themes.
|
- Keyboard shortcut to cycle themes.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
||||||
- Streaming markdown on mobile renders significantly faster.
|
- Streaming markdown on mobile renders significantly faster.
|
||||||
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
||||||
@@ -100,6 +200,7 @@
|
|||||||
- Relay and pairing URLs are stripped from daemon logs.
|
- Relay and pairing URLs are stripped from daemon logs.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Closed agent tabs no longer reappear after reconnecting.
|
- Closed agent tabs no longer reappear after reconnecting.
|
||||||
- Desktop notification badge counts match across all workspaces.
|
- Desktop notification badge counts match across all workspaces.
|
||||||
- Host switcher status syncs correctly when switching between hosts.
|
- Host switcher status syncs correctly when switching between hosts.
|
||||||
@@ -107,11 +208,13 @@
|
|||||||
## 0.1.52 - 2026-04-10
|
## 0.1.52 - 2026-04-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
||||||
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
||||||
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
||||||
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
||||||
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
||||||
@@ -124,11 +227,13 @@
|
|||||||
## 0.1.51 - 2026-04-09
|
## 0.1.51 - 2026-04-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
||||||
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
||||||
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Model selector no longer crashes on iPad.
|
- Model selector no longer crashes on iPad.
|
||||||
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
||||||
- OpenCode agents show the correct terminal state and refresh models reliably.
|
- OpenCode agents show the correct terminal state and refresh models reliably.
|
||||||
@@ -140,6 +245,7 @@
|
|||||||
## 0.1.50 - 2026-04-07
|
## 0.1.50 - 2026-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||||
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||||
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||||
@@ -148,6 +254,7 @@
|
|||||||
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Workspaces load instantly on connect instead of waiting for a full sync.
|
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||||
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||||
- Closing a workspace tab is now instant.
|
- Closing a workspace tab is now instant.
|
||||||
@@ -155,6 +262,7 @@
|
|||||||
- Reload agent moved away from the close button to prevent accidental taps.
|
- Reload agent moved away from the close button to prevent accidental taps.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Voice mode no longer drifts into false speech detection during long sessions.
|
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||||
- Garbled overlapping text on plan cards.
|
- Garbled overlapping text on plan cards.
|
||||||
- Changes pane could show stale diffs when working with git worktrees.
|
- Changes pane could show stale diffs when working with git worktrees.
|
||||||
@@ -167,6 +275,7 @@
|
|||||||
## 0.1.49 - 2026-04-07
|
## 0.1.49 - 2026-04-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||||
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||||
- Model lists stay consistent regardless of which screen you open first.
|
- Model lists stay consistent regardless of which screen you open first.
|
||||||
@@ -174,17 +283,20 @@
|
|||||||
## 0.1.48 - 2026-04-05
|
## 0.1.48 - 2026-04-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
||||||
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
||||||
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
||||||
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
||||||
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
||||||
- Removed noisy agent lifecycle toasts that fired on every state change.
|
- Removed noisy agent lifecycle toasts that fired on every state change.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
||||||
- Input field on running agent screens now correctly receives keyboard focus.
|
- Input field on running agent screens now correctly receives keyboard focus.
|
||||||
- Mobile model selector alignment and sizing.
|
- Mobile model selector alignment and sizing.
|
||||||
@@ -192,6 +304,7 @@
|
|||||||
## 0.1.47 - 2026-04-05
|
## 0.1.47 - 2026-04-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
||||||
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
||||||
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
||||||
@@ -200,6 +313,7 @@
|
|||||||
## 0.1.46 - 2026-04-04
|
## 0.1.46 - 2026-04-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
||||||
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
||||||
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
||||||
@@ -208,6 +322,7 @@
|
|||||||
## 0.1.45 - 2026-04-04
|
## 0.1.45 - 2026-04-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||||
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||||
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||||
@@ -220,11 +335,13 @@
|
|||||||
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||||
- Settings sections reordered for better grouping (integrations and daemon together).
|
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||||
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||||
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||||
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||||
@@ -241,6 +358,7 @@
|
|||||||
## 0.1.44 - 2026-04-03
|
## 0.1.44 - 2026-04-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||||
- Disabled claude-acp and copilot providers from the agent registry.
|
- Disabled claude-acp and copilot providers from the agent registry.
|
||||||
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||||
@@ -250,16 +368,19 @@
|
|||||||
## 0.1.43 - 2026-04-02
|
## 0.1.43 - 2026-04-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||||
- Searchable model favorites — quickly find and pin preferred models.
|
- Searchable model favorites — quickly find and pin preferred models.
|
||||||
- Slash command support for OpenCode agents.
|
- Slash command support for OpenCode agents.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Refined model selector UX with better mobile sheet behavior.
|
- Refined model selector UX with better mobile sheet behavior.
|
||||||
- Workspace status now uses amber alert styling for "needs input" state.
|
- Workspace status now uses amber alert styling for "needs input" state.
|
||||||
- Themed scrollbar on message input for consistent styling.
|
- Themed scrollbar on message input for consistent styling.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||||
- Shell arguments with spaces are now properly quoted on Windows.
|
- Shell arguments with spaces are now properly quoted on Windows.
|
||||||
- Claude models with 1M context support are now correctly reported.
|
- Claude models with 1M context support are now correctly reported.
|
||||||
@@ -267,11 +388,13 @@
|
|||||||
## 0.1.42 - 2026-04-01
|
## 0.1.42 - 2026-04-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
||||||
|
|
||||||
## 0.1.41 - 2026-04-01
|
## 0.1.41 - 2026-04-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||||
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||||
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||||
@@ -283,18 +406,22 @@
|
|||||||
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||||
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||||
|
|
||||||
## 0.1.40 - 2026-04-01
|
## 0.1.40 - 2026-04-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Workspace tabs can now be closed in batches.
|
- Workspace tabs can now be closed in batches.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||||
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||||
- Archive tab now correctly reconciles agent visibility after archiving.
|
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||||
@@ -305,61 +432,73 @@
|
|||||||
## 0.1.39 - 2026-03-30
|
## 0.1.39 - 2026-03-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
||||||
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
||||||
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
||||||
|
|
||||||
## 0.1.38 - 2026-03-30
|
## 0.1.38 - 2026-03-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
||||||
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
||||||
|
|
||||||
## 0.1.37 - 2026-03-29
|
## 0.1.37 - 2026-03-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
|
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
|
||||||
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
|
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed broken PATH propagation and Claude binary resolution on Windows.
|
- Fixed broken PATH propagation and Claude binary resolution on Windows.
|
||||||
- Dictation errors now show a visible toast instead of failing silently.
|
- Dictation errors now show a visible toast instead of failing silently.
|
||||||
|
|
||||||
## 0.1.36 - 2026-03-27
|
## 0.1.36 - 2026-03-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Windows drive-letter path handling across the codebase.
|
- Fixed Windows drive-letter path handling across the codebase.
|
||||||
- Fixed stale Nix hash with automatic lockfile-change detection.
|
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added metrics collection and terminal performance tests.
|
- Added metrics collection and terminal performance tests.
|
||||||
|
|
||||||
## 0.1.35 - 2026-03-26
|
## 0.1.35 - 2026-03-26
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||||
- Codex file deletions now display correctly as removed lines in diffs.
|
- Codex file deletions now display correctly as removed lines in diffs.
|
||||||
- OpenCode questions are now surfaced in the permission UI.
|
- OpenCode questions are now surfaced in the permission UI.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed queued prompt dispatch after idle transition.
|
- Fixed queued prompt dispatch after idle transition.
|
||||||
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support for Nix and NixOS installation.
|
- Added support for Nix and NixOS installation.
|
||||||
|
|
||||||
## 0.1.34 - 2026-03-25
|
## 0.1.34 - 2026-03-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||||
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||||
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||||
- Linked Android download surfaces to the Google Play Store.
|
- Linked Android download surfaces to the Google Play Store.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||||
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||||
- Restored per-provider form preferences and removed the Auto model fallback.
|
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||||
@@ -368,6 +507,7 @@
|
|||||||
- Improved chat transcript readability.
|
- Improved chat transcript readability.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed `paseo send --no-wait` not taking effect.
|
- Fixed `paseo send --no-wait` not taking effect.
|
||||||
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||||
- Fixed Claude interrupt handling and autonomous wake reliability.
|
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||||
@@ -384,6 +524,7 @@
|
|||||||
## 0.1.33 - 2026-03-23
|
## 0.1.33 - 2026-03-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||||
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||||
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||||
@@ -392,6 +533,7 @@
|
|||||||
## 0.1.32 - 2026-03-23
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||||
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||||
- Added line numbers and word-wrap toggle to file previews.
|
- Added line numbers and word-wrap toggle to file previews.
|
||||||
@@ -401,6 +543,7 @@
|
|||||||
- Added status bar tooltips for project and agent status.
|
- Added status bar tooltips for project and agent status.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||||
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||||
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||||
@@ -415,6 +558,7 @@
|
|||||||
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||||
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||||
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||||
@@ -423,16 +567,19 @@
|
|||||||
## 0.1.30 - 2026-03-19
|
## 0.1.30 - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||||
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||||
- Added Open Graph metadata improvements for richer website sharing previews.
|
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||||
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||||
- Improved project picker path display and general workspace UI polish.
|
- Improved project picker path display and general workspace UI polish.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||||
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||||
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||||
@@ -440,30 +587,36 @@
|
|||||||
## 0.1.28 - 2026-03-15
|
## 0.1.28 - 2026-03-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added OpenCode build and plan modes.
|
- Added OpenCode build and plan modes.
|
||||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved the git action menu for more reliable repository actions.
|
- Improved the git action menu for more reliable repository actions.
|
||||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||||
- Updated the website hero copy and added a sponsor callout section.
|
- Updated the website hero copy and added a sponsor callout section.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||||
|
|
||||||
## 0.1.27 - 2026-03-13
|
## 0.1.27 - 2026-03-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added voice runtime with new audio engine architecture for voice interactions.
|
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||||
- Added Grep tool support in Claude tool-call mapping.
|
- Added Grep tool support in Claude tool-call mapping.
|
||||||
- Added ability to open workspace files directly from agent chat messages.
|
- Added ability to open workspace files directly from agent chat messages.
|
||||||
- Added desktop notifications via a custom native bridge.
|
- Added desktop notifications via a custom native bridge.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved image picker, markdown rendering, and UI interactions.
|
- Improved image picker, markdown rendering, and UI interactions.
|
||||||
- Improved shell environment detection using shell-env.
|
- Improved shell environment detection using shell-env.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed platform-specific markdown link rendering.
|
- Fixed platform-specific markdown link rendering.
|
||||||
- Fixed Linux AppImage CLI resource paths.
|
- Fixed Linux AppImage CLI resource paths.
|
||||||
- Fixed Codex replacement stream being killed by stale turn notifications.
|
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||||
@@ -471,15 +624,18 @@
|
|||||||
## 0.1.26 - 2026-03-12
|
## 0.1.26 - 2026-03-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
|
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
|
||||||
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
|
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
|
||||||
- Added Windows support with improved cross-platform shell execution.
|
- Added Windows support with improved cross-platform shell execution.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
|
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
|
||||||
- Added a Discord link to the website navigation.
|
- Added a Discord link to the website navigation.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
|
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
|
||||||
- Fixed the home route to hide browser chrome when appropriate.
|
- Fixed the home route to hide browser chrome when appropriate.
|
||||||
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
|
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
|
||||||
@@ -489,61 +645,79 @@
|
|||||||
## 0.1.25 - 2026-03-11
|
## 0.1.25 - 2026-03-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
|
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
|
||||||
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
|
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
|
||||||
|
|
||||||
## 0.1.24 - 2026-03-10
|
## 0.1.24 - 2026-03-10
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved command center keyboard navigation and new tab shortcut.
|
- Improved command center keyboard navigation and new tab shortcut.
|
||||||
- Simplified desktop release pipeline for faster and more reliable builds.
|
- Simplified desktop release pipeline for faster and more reliable builds.
|
||||||
|
|
||||||
## 0.1.21 - 2026-03-10
|
## 0.1.21 - 2026-03-10
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
|
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
|
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
|
||||||
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
|
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
|
||||||
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
|
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
|
||||||
|
|
||||||
## 0.1.20 - 2026-03-10
|
## 0.1.20 - 2026-03-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added workspace sidebar git actions with quick diff stats and archive controls.
|
- Added workspace sidebar git actions with quick diff stats and archive controls.
|
||||||
- Added refreshed website downloads and homepage presentation for desktop installs.
|
- Added refreshed website downloads and homepage presentation for desktop installs.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
|
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
|
||||||
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
|
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
|
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
|
||||||
|
|
||||||
## 0.1.19 - 2026-03-09
|
## 0.1.19 - 2026-03-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
|
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
|
||||||
|
|
||||||
## 0.1.18 - 2026-03-06
|
## 0.1.18 - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a desktop `Mod+W` shortcut to close the current tab.
|
- Added a desktop `Mod+W` shortcut to close the current tab.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- New and newly selected terminals now take focus automatically so you can type immediately.
|
- New and newly selected terminals now take focus automatically so you can type immediately.
|
||||||
- Kept newly created workspaces and projects in a more stable order in the sidebar.
|
- Kept newly created workspaces and projects in a more stable order in the sidebar.
|
||||||
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
|
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
|
||||||
- Updated the website desktop download link to use the universal macOS DMG.
|
- Updated the website desktop download link to use the universal macOS DMG.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Restored automatic agent metadata generation for Claude runs.
|
- Restored automatic agent metadata generation for Claude runs.
|
||||||
|
|
||||||
## 0.1.17 - 2026-03-06
|
## 0.1.17 - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
|
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
|
||||||
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
|
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
|
||||||
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
|
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
|
||||||
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
|
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
|
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
|
||||||
- More reliable workspace routing and tab identity handling across refreshes and deep links.
|
- More reliable workspace routing and tab identity handling across refreshes and deep links.
|
||||||
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
|
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
|
||||||
@@ -551,6 +725,7 @@
|
|||||||
- Stronger provider error surfacing and updated Claude model/runtime handling.
|
- Stronger provider error surfacing and updated Claude model/runtime handling.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed orphan workspace runs caused by non-canonical tab routes.
|
- Fixed orphan workspace runs caused by non-canonical tab routes.
|
||||||
- Fixed mobile terminal tab remount/routing restore issues.
|
- Fixed mobile terminal tab remount/routing restore issues.
|
||||||
- Fixed agent metadata title/branch update reliability.
|
- Fixed agent metadata title/branch update reliability.
|
||||||
@@ -558,7 +733,9 @@
|
|||||||
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
|
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
|
||||||
|
|
||||||
## 0.1.16 - 2026-02-22
|
## 0.1.16 - 2026-02-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Update the Paseo desktop app and local daemon directly from Settings.
|
- Update the Paseo desktop app and local daemon directly from Settings.
|
||||||
- Microphone and notification permission controls in Settings.
|
- Microphone and notification permission controls in Settings.
|
||||||
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
|
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
|
||||||
@@ -566,6 +743,7 @@
|
|||||||
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
|
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
|
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
|
||||||
- Faster agent sidebar list rendering.
|
- Faster agent sidebar list rendering.
|
||||||
- Archiving an agent now stops it first instead of archiving a half-running session.
|
- Archiving an agent now stops it first instead of archiving a half-running session.
|
||||||
@@ -573,16 +751,20 @@
|
|||||||
- More reliable relay connections.
|
- More reliable relay connections.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Claude background tasks desyncing the chat.
|
- Fixed Claude background tasks desyncing the chat.
|
||||||
- Fixed duplicate user messages appearing in the timeline.
|
- Fixed duplicate user messages appearing in the timeline.
|
||||||
- Fixed a startup crash caused by an OpenCode SDK update.
|
- Fixed a startup crash caused by an OpenCode SDK update.
|
||||||
- Fixed spurious "needs attention" notifications from background agent activity.
|
- Fixed spurious "needs attention" notifications from background agent activity.
|
||||||
|
|
||||||
## 0.1.15 - 2026-02-19
|
## 0.1.15 - 2026-02-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a public changelog page on the website so users can browse release notes.
|
- Added a public changelog page on the website so users can browse release notes.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||||
- Simplified website GitHub navigation and changelog headings.
|
- Simplified website GitHub navigation and changelog headings.
|
||||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||||
@@ -590,11 +772,14 @@
|
|||||||
- Hid empty filter groups in the left sidebar.
|
- Hid empty filter groups in the left sidebar.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
|
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
|
||||||
- Fixed duplicate `/rewind` user-message behavior.
|
- Fixed duplicate `/rewind` user-message behavior.
|
||||||
|
|
||||||
## 0.1.14 - 2026-02-19
|
## 0.1.14 - 2026-02-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added Claude `/rewind` command support.
|
- Added Claude `/rewind` command support.
|
||||||
- Added slash command access in the draft agent composer.
|
- Added slash command access in the draft agent composer.
|
||||||
- Added `@` workspace file autocomplete in chat prompts.
|
- Added `@` workspace file autocomplete in chat prompts.
|
||||||
@@ -603,6 +788,7 @@
|
|||||||
- Added shared desktop/web overlay scroll handles, including file preview panes.
|
- Added shared desktop/web overlay scroll handles, including file preview panes.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved worktree flow after shipping, including better merged PR detection.
|
- Improved worktree flow after shipping, including better merged PR detection.
|
||||||
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
|
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
|
||||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||||
@@ -612,6 +798,7 @@
|
|||||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
|
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
|
||||||
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
|
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
|
||||||
- Fixed terminal sizing when switching back to an agent session.
|
- Fixed terminal sizing when switching back to an agent session.
|
||||||
@@ -625,25 +812,32 @@
|
|||||||
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
|
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
|
||||||
|
|
||||||
## 0.1.9 - 2026-02-17
|
## 0.1.9 - 2026-02-17
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||||
- Reused provider availability checks for structured generation fallback selection.
|
- Reused provider availability checks for structured generation fallback selection.
|
||||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||||
|
|
||||||
## 0.1.8 - 2026-02-17
|
## 0.1.8 - 2026-02-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||||
- Updated website hero copy to emphasize local execution.
|
- Updated website hero copy to emphasize local execution.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||||
- Fixed Claude session identity preservation when reloading existing agents.
|
- Fixed Claude session identity preservation when reloading existing agents.
|
||||||
- Fixed combobox option behavior and related interactions.
|
- Fixed combobox option behavior and related interactions.
|
||||||
@@ -651,73 +845,93 @@
|
|||||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||||
|
|
||||||
## 0.1.7 - 2026-02-16
|
## 0.1.7 - 2026-02-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Improved agent workspace flows with better directory suggestions.
|
- Improved agent workspace flows with better directory suggestions.
|
||||||
- Added iOS TestFlight and Android app access request forms on the website.
|
- Added iOS TestFlight and Android app access request forms on the website.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||||
- Improved website app download and update guidance.
|
- Improved website app download and update guidance.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Prevented an initial desktop combobox `0,0` position flash.
|
- Prevented an initial desktop combobox `0,0` position flash.
|
||||||
- Fixed CLI version output issues.
|
- Fixed CLI version output issues.
|
||||||
- Hardened server runtime loading for local speech dependencies.
|
- Hardened server runtime loading for local speech dependencies.
|
||||||
|
|
||||||
## 0.1.6 - 2026-02-16
|
## 0.1.6 - 2026-02-16
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- No major visible product changes in this patch release.
|
- No major visible product changes in this patch release.
|
||||||
|
|
||||||
## 0.1.5 - 2026-02-16
|
## 0.1.5 - 2026-02-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added terminal reattach support and better worktree terminal handling.
|
- Added terminal reattach support and better worktree terminal handling.
|
||||||
- Added global keyboard shortcut help in the app.
|
- Added global keyboard shortcut help in the app.
|
||||||
- Added sidebar host filtering and improved agent workflow controls.
|
- Added sidebar host filtering and improved agent workflow controls.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved worktree setup visibility by streaming setup progress.
|
- Improved worktree setup visibility by streaming setup progress.
|
||||||
- Improved terminal streaming reliability and lifecycle handling.
|
- Improved terminal streaming reliability and lifecycle handling.
|
||||||
- Preserved explorer tab state so context survives navigation better.
|
- Preserved explorer tab state so context survives navigation better.
|
||||||
|
|
||||||
## 0.1.4 - 2026-02-14
|
## 0.1.4 - 2026-02-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added voice capability status reporting in the client.
|
- Added voice capability status reporting in the client.
|
||||||
- Added background local speech model downloads with runtime gating.
|
- Added background local speech model downloads with runtime gating.
|
||||||
- Added adaptive dictation finish timing based on server-provided budgets.
|
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||||
- Added relay reconnect behavior with grace periods and branch suggestions.
|
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved connection selection and agent hydration reliability.
|
- Improved connection selection and agent hydration reliability.
|
||||||
- Improved timeline loading with cursor-based fetch behavior.
|
- Improved timeline loading with cursor-based fetch behavior.
|
||||||
- Improved first-run experience by bootstrapping a default localhost connection.
|
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||||
- Improved inline code rendering by auto-linkifying URLs.
|
- Improved inline code rendering by auto-linkifying URLs.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||||
- Fixed stale relay client timer behavior.
|
- Fixed stale relay client timer behavior.
|
||||||
- Fixed unnecessary git diff header auto-scroll on collapse.
|
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||||
|
|
||||||
## 0.1.3 - 2026-02-12
|
## 0.1.3 - 2026-02-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added CLI onboarding command.
|
- Added CLI onboarding command.
|
||||||
- Added CLI `--output-schema` support for structured agent output.
|
- Added CLI `--output-schema` support for structured agent output.
|
||||||
- Added CLI agent metadata update support for names and labels.
|
- Added CLI agent metadata update support for names and labels.
|
||||||
- Added provider availability detection with normalization of legacy default model IDs.
|
- Added provider availability detection with normalization of legacy default model IDs.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||||
- Added better voice interrupt handling with a speech-start grace period.
|
- Added better voice interrupt handling with a speech-start grace period.
|
||||||
- Improved CLI defaults to list all non-archived agents by default.
|
- Improved CLI defaults to list all non-archived agents by default.
|
||||||
- Improved website UX with clearer install CTA and privacy policy access.
|
- Improved website UX with clearer install CTA and privacy policy access.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||||
|
|
||||||
## 0.1.2 - 2026-02-11
|
## 0.1.2 - 2026-02-11
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- No major visible product changes in this patch release.
|
- No major visible product changes in this patch release.
|
||||||
|
|
||||||
## 0.1.1 - 2026-02-11
|
## 0.1.1 - 2026-02-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial `0.1.x` release line.
|
- Initial `0.1.x` release line.
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Doc | What's in it |
|
| Doc | What's in it |
|
||||||
|---|---|
|
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||||
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
npm run typecheck # Always run after changes
|
||||||
|
npm run lint # Always run after changes
|
||||||
npm run format # Auto-format with Biome
|
npm run format # Auto-format with Biome
|
||||||
npm run format:check # Check formatting without writing
|
npm run format:check # Check formatting without writing
|
||||||
```
|
```
|
||||||
@@ -53,8 +54,11 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck and lint after every change.**
|
||||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||||
|
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||||
|
- `npm run lint -- packages/app/src/components/message.tsx`
|
||||||
|
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
- Never change a field from optional to required.
|
- Never change a field from optional to required.
|
||||||
@@ -68,23 +72,23 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
|
|||||||
|
|
||||||
### The four gates
|
### The four gates
|
||||||
|
|
||||||
| Gate | Type | When to use |
|
| Gate | Type | When to use |
|
||||||
|---|---|---|
|
| -------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
||||||
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
||||||
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
||||||
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
||||||
|
|
||||||
### Decision matrix
|
### Decision matrix
|
||||||
|
|
||||||
| I need to... | Use |
|
| I need to... | Use |
|
||||||
|---|---|
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
||||||
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
||||||
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
||||||
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
||||||
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
||||||
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
@@ -104,5 +108,4 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
|
|||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
|
||||||
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ paseo
|
|||||||
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
|
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ Then use them in any agent conversation:
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
Quick monorepo package map:
|
Quick monorepo package map:
|
||||||
|
|
||||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||||
- `packages/app`: Expo client (iOS, Android, web)
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"includes": ["**", "!*.lock"]
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"indentStyle": "space",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"lineWidth": 100
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double",
|
|
||||||
"trailingCommas": "all",
|
|
||||||
"semicolons": "always"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"css": {
|
|
||||||
"parser": {
|
|
||||||
"cssModules": true,
|
|
||||||
"tailwindDirectives": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,6 +58,7 @@ await rm(staticDir, { recursive: true, force: true });
|
|||||||
```
|
```
|
||||||
|
|
||||||
Run with:
|
Run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsx packages/server/src/server/your-script.ts
|
npx tsx packages/server/src/server/your-script.ts
|
||||||
```
|
```
|
||||||
@@ -107,6 +108,7 @@ const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "i
|
|||||||
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||||
|
|
||||||
Always pass `appVersion`:
|
Always pass `appVersion`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: `ws://127.0.0.1:${port}/ws`,
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
|||||||
+3
-3
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
|
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
|
||||||
|
|
||||||
| Variant | App name | Package ID |
|
| Variant | App name | Package ID |
|
||||||
|---|---|---|
|
| ------------- | ----------- | ---------------- |
|
||||||
| `production` | Paseo | `sh.paseo` |
|
| `production` | Paseo | `sh.paseo` |
|
||||||
| `development` | Paseo Debug | `sh.paseo.debug` |
|
| `development` | Paseo Debug | `sh.paseo.debug` |
|
||||||
|
|
||||||
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
|
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
|
||||||
|
|||||||
+18
-16
@@ -53,17 +53,17 @@ The heart of Paseo. A Node.js process that:
|
|||||||
|
|
||||||
**Key modules:**
|
**Key modules:**
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
|---|---|
|
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||||
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||||
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
||||||
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||||
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||||
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||||
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||||
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
||||||
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||||
|
|
||||||
### `packages/app` — Mobile + web client (Expo)
|
### `packages/app` — Mobile + web client (Expo)
|
||||||
|
|
||||||
@@ -132,6 +132,7 @@ Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capab
|
|||||||
**Binary multiplexing:**
|
**Binary multiplexing:**
|
||||||
|
|
||||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||||
|
|
||||||
- Channel 0: control messages
|
- Channel 0: control messages
|
||||||
- Channel 1: terminal data
|
- Channel 1: terminal data
|
||||||
- 1-byte channel ID + 1-byte flags + variable payload
|
- 1-byte channel ID + 1-byte flags + variable payload
|
||||||
@@ -153,13 +154,14 @@ initializing → idle → running → idle (or error → closed)
|
|||||||
|
|
||||||
Each provider implements a common `AgentClient` interface:
|
Each provider implements a common `AgentClient` interface:
|
||||||
|
|
||||||
| Provider | Wraps | Session format |
|
| Provider | Wraps | Session format |
|
||||||
|---|---|---|
|
| -------- | ------------------- | -------------------------------------------------- |
|
||||||
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||||
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||||
| OpenCode | OpenCode CLI | Provider-managed |
|
| OpenCode | OpenCode CLI | Provider-managed |
|
||||||
|
|
||||||
All providers:
|
All providers:
|
||||||
|
|
||||||
- Handle their own authentication (Paseo does not manage API keys)
|
- Handle their own authentication (Paseo does not manage API keys)
|
||||||
- Support session resume via persistence handles
|
- Support session resume via persistence handles
|
||||||
- Map tool calls to a normalized `ToolCallDetail` type
|
- Map tool calls to a normalized `ToolCallDetail` type
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ No complex inline types in public function signatures.
|
|||||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||||
|
|
||||||
// Good
|
// Good
|
||||||
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
|
interface EnqueueJobInput {
|
||||||
|
userId: string;
|
||||||
|
priority: "low" | "normal" | "high";
|
||||||
|
}
|
||||||
function enqueueJob(input: EnqueueJobInput) {}
|
function enqueueJob(input: EnqueueJobInput) {}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -53,7 +56,11 @@ If a function needs more than one argument, use a single object parameter.
|
|||||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||||
|
|
||||||
// Good: object param
|
// Good: object param
|
||||||
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
|
interface CreateToolCallInput {
|
||||||
|
provider: string;
|
||||||
|
toolName: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
function createToolCall(input: CreateToolCallInput) {}
|
function createToolCall(input: CreateToolCallInput) {}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -78,7 +85,11 @@ Use discriminated unions instead of bags of booleans and optionals.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Bad
|
// Bad
|
||||||
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
|
interface FetchState {
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
data?: Data;
|
||||||
|
}
|
||||||
|
|
||||||
// Good
|
// Good
|
||||||
type FetchState =
|
type FetchState =
|
||||||
@@ -136,7 +147,10 @@ Avoid packing branching, lookup, and transformation into single dense expression
|
|||||||
// Bad: nested ternaries + inline lookups
|
// Bad: nested ternaries + inline lookups
|
||||||
const billing = shouldUseLegacy(account)
|
const billing = shouldUseLegacy(account)
|
||||||
? getLegacy(account)
|
? getLegacy(account)
|
||||||
: buildBilling(account, rates.find((r) => r.region === account.region));
|
: buildBilling(
|
||||||
|
account,
|
||||||
|
rates.find((r) => r.region === account.region),
|
||||||
|
);
|
||||||
|
|
||||||
// Good: named steps, then assemble
|
// Good: named steps, then assemble
|
||||||
const rate = rates.find((r) => r.region === account.region);
|
const rate = rates.find((r) => r.region === account.region);
|
||||||
|
|||||||
+86
-47
@@ -55,6 +55,7 @@ Use `extends` to create a new provider entry that inherits from a built-in provi
|
|||||||
```
|
```
|
||||||
|
|
||||||
Required fields for custom providers:
|
Required fields for custom providers:
|
||||||
|
|
||||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||||
- `label` — display name in the UI
|
- `label` — display name in the UI
|
||||||
|
|
||||||
@@ -95,12 +96,12 @@ Required fields for custom providers:
|
|||||||
|
|
||||||
### Available models
|
### Available models
|
||||||
|
|
||||||
| Model | Tier |
|
| Model | Tier |
|
||||||
|---|---|
|
| ------------- | ------------------- |
|
||||||
| `glm-5.1` | Advanced (flagship) |
|
| `glm-5.1` | Advanced (flagship) |
|
||||||
| `glm-5-turbo` | Advanced |
|
| `glm-5-turbo` | Advanced |
|
||||||
| `glm-4.7` | Standard |
|
| `glm-4.7` | Standard |
|
||||||
| `glm-4.5-air` | Lightweight |
|
| `glm-4.5-air` | Lightweight |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
@@ -148,10 +149,10 @@ Required fields for custom providers:
|
|||||||
|
|
||||||
### API endpoints
|
### API endpoints
|
||||||
|
|
||||||
| Mode | Base URL |
|
| Mode | Base URL |
|
||||||
|---|---|
|
| ------------------------------- | ----------------------------------------------------------- |
|
||||||
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
||||||
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||||
|
|
||||||
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
||||||
|
|
||||||
@@ -159,13 +160,13 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
|
|
||||||
**Recommended for coding plan:**
|
**Recommended for coding plan:**
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
|---|---|
|
| ------------------ | --------------------------- |
|
||||||
| `qwen3.5-plus` | Vision capable, recommended |
|
| `qwen3.5-plus` | Vision capable, recommended |
|
||||||
| `qwen3-coder-next` | Optimized for coding |
|
| `qwen3-coder-next` | Optimized for coding |
|
||||||
| `kimi-k2.5` | Vision capable |
|
| `kimi-k2.5` | Vision capable |
|
||||||
| `glm-5` | Zhipu GLM |
|
| `glm-5` | Zhipu GLM |
|
||||||
| `MiniMax-M2.5` | MiniMax |
|
| `MiniMax-M2.5` | MiniMax |
|
||||||
|
|
||||||
**Additional models (pay-as-you-go):**
|
**Additional models (pay-as-you-go):**
|
||||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||||
@@ -221,16 +222,12 @@ You can also combine profiles with model overrides to pin specific models per pr
|
|||||||
"claude-fast": {
|
"claude-fast": {
|
||||||
"extends": "claude",
|
"extends": "claude",
|
||||||
"label": "Claude (Fast)",
|
"label": "Claude (Fast)",
|
||||||
"models": [
|
"models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }]
|
||||||
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"claude-smart": {
|
"claude-smart": {
|
||||||
"extends": "claude",
|
"extends": "claude",
|
||||||
"label": "Claude (Smart)",
|
"label": "Claude (Smart)",
|
||||||
"models": [
|
"models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }]
|
||||||
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +335,7 @@ Set `extends: "acp"` and provide a `command`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Required fields for ACP providers:
|
Required fields for ACP providers:
|
||||||
|
|
||||||
- `extends: "acp"`
|
- `extends: "acp"`
|
||||||
- `label`
|
- `label`
|
||||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||||
@@ -424,44 +422,85 @@ Models and modes are discovered dynamically at runtime from the agent process. I
|
|||||||
|
|
||||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||||
|
|
||||||
|
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
|
||||||
|
|
||||||
|
Example: add an experimental model while keeping every model the provider discovers at runtime:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"providers": {
|
||||||
|
"my-agent": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "My Agent",
|
||||||
|
"command": ["my-agent", "--acp"],
|
||||||
|
"additionalModels": [
|
||||||
|
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: relabel a discovered model without replacing the full list:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"providers": {
|
||||||
|
"my-agent": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "My Agent",
|
||||||
|
"command": ["my-agent", "--acp"],
|
||||||
|
"additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Provider override reference
|
## Provider override reference
|
||||||
|
|
||||||
Every entry under `agents.providers` accepts these fields:
|
Every entry under `agents.providers` accepts these fields:
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
|
||||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||||
| `description` | `string` | No | Short description shown in the UI |
|
| `description` | `string` | No | Short description shown in the UI |
|
||||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||||
| `order` | `number` | No | Sort order in the provider list |
|
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||||
|
| `order` | `number` | No | Sort order in the provider list |
|
||||||
|
|
||||||
### Model definition
|
### Model definition
|
||||||
|
|
||||||
Each entry in the `models` array:
|
Each entry in the `models` array:
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
| ----------------- | ------------------ | -------- | ------------------------------------- |
|
||||||
| `id` | `string` | Yes | Model identifier sent to the provider |
|
| `id` | `string` | Yes | Model identifier sent to the provider |
|
||||||
| `label` | `string` | Yes | Display name in the UI |
|
| `label` | `string` | Yes | Display name in the UI |
|
||||||
| `description` | `string` | No | Short description |
|
| `description` | `string` | No | Short description |
|
||||||
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
||||||
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
||||||
|
|
||||||
### Thinking option
|
### Thinking option
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
| ------------- | --------- | -------- | ----------------------------------- |
|
||||||
| `id` | `string` | Yes | Thinking option identifier |
|
| `id` | `string` | Yes | Thinking option identifier |
|
||||||
| `label` | `string` | Yes | Display name |
|
| `label` | `string` | Yes | Display name |
|
||||||
| `description` | `string` | No | Short description |
|
| `description` | `string` | No | Short description |
|
||||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||||
|
|
||||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
|||||||
+200
-200
@@ -34,88 +34,88 @@ $PASEO_HOME/
|
|||||||
|
|
||||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
| `id` | `string` | UUID, primary key |
|
| `id` | `string` | UUID, primary key |
|
||||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||||
| `cwd` | `string` | Working directory the agent operates in |
|
| `cwd` | `string` | Working directory the agent operates in |
|
||||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `title` | `string?` | User-visible title |
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
### Nested: SerializableConfig
|
### Nested: SerializableConfig
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------------ | -------------------------- | ---------------------------- |
|
||||||
| `title` | `string?` | Configured title |
|
| `title` | `string?` | Configured title |
|
||||||
| `modeId` | `string?` | Configured mode |
|
| `modeId` | `string?` | Configured mode |
|
||||||
| `model` | `string?` | Configured model |
|
| `model` | `string?` | Configured model |
|
||||||
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
||||||
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
||||||
| `extra` | `Record<string, any>?` | Provider-specific config |
|
| `extra` | `Record<string, any>?` | Provider-specific config |
|
||||||
| `systemPrompt` | `string?` | Custom system prompt |
|
| `systemPrompt` | `string?` | Custom system prompt |
|
||||||
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
||||||
|
|
||||||
### Nested: RuntimeInfo
|
### Nested: RuntimeInfo
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------------ | -------------------------- | ------------------------------ |
|
||||||
| `provider` | `string` | Active provider |
|
| `provider` | `string` | Active provider |
|
||||||
| `sessionId` | `string?` | Active session ID |
|
| `sessionId` | `string?` | Active session ID |
|
||||||
| `model` | `string?` | Active model |
|
| `model` | `string?` | Active model |
|
||||||
| `thinkingOptionId` | `string?` | Active thinking option |
|
| `thinkingOptionId` | `string?` | Active thinking option |
|
||||||
| `modeId` | `string?` | Active mode |
|
| `modeId` | `string?` | Active mode |
|
||||||
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
||||||
|
|
||||||
### Nested: PersistenceHandle
|
### Nested: PersistenceHandle
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| -------------- | ---------------------- | --------------------------------------------------------------------- |
|
||||||
| `provider` | `string` | Provider that owns the session |
|
| `provider` | `string` | Provider that owns the session |
|
||||||
| `sessionId` | `string` | Session ID for resumption |
|
| `sessionId` | `string` | Session ID for resumption |
|
||||||
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
||||||
| `metadata` | `Record<string, any>?` | Extra metadata |
|
| `metadata` | `Record<string, any>?` | Extra metadata |
|
||||||
|
|
||||||
### Nested: AgentFeature (discriminated union on `type`)
|
### Nested: AgentFeature (discriminated union on `type`)
|
||||||
|
|
||||||
**Toggle:**
|
**Toggle:**
|
||||||
|
|
||||||
| Field | Type |
|
| Field | Type |
|
||||||
|---|---|
|
| ------------- | ---------- |
|
||||||
| `type` | `"toggle"` |
|
| `type` | `"toggle"` |
|
||||||
| `id` | `string` |
|
| `id` | `string` |
|
||||||
| `label` | `string` |
|
| `label` | `string` |
|
||||||
| `description` | `string?` |
|
| `description` | `string?` |
|
||||||
| `tooltip` | `string?` |
|
| `tooltip` | `string?` |
|
||||||
| `icon` | `string?` |
|
| `icon` | `string?` |
|
||||||
| `value` | `boolean` |
|
| `value` | `boolean` |
|
||||||
|
|
||||||
**Select:**
|
**Select:**
|
||||||
|
|
||||||
| Field | Type |
|
| Field | Type |
|
||||||
|---|---|
|
| ------------- | --------------------- |
|
||||||
| `type` | `"select"` |
|
| `type` | `"select"` |
|
||||||
| `id` | `string` |
|
| `id` | `string` |
|
||||||
| `label` | `string` |
|
| `label` | `string` |
|
||||||
| `description` | `string?` |
|
| `description` | `string?` |
|
||||||
| `tooltip` | `string?` |
|
| `tooltip` | `string?` |
|
||||||
| `icon` | `string?` |
|
| `icon` | `string?` |
|
||||||
| `value` | `string?` |
|
| `value` | `string?` |
|
||||||
| `options` | `AgentSelectOption[]` |
|
| `options` | `AgentSelectOption[]` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -172,22 +172,22 @@ All fields are optional with sensible defaults.
|
|||||||
|
|
||||||
One file per schedule. ID is 8 hex characters.
|
One file per schedule. ID is 8 hex characters.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ----------- | ------------------------------------- | -------------------------------- |
|
||||||
| `id` | `string` | 8-char hex ID |
|
| `id` | `string` | 8-char hex ID |
|
||||||
| `name` | `string?` | Human-readable name |
|
| `name` | `string?` | Human-readable name |
|
||||||
| `prompt` | `string` | The prompt to send |
|
| `prompt` | `string` | The prompt to send |
|
||||||
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
||||||
| `target` | `ScheduleTarget` | What to run (see below) |
|
| `target` | `ScheduleTarget` | What to run (see below) |
|
||||||
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
||||||
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
||||||
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
||||||
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
||||||
| `maxRuns` | `number?` | Max executions before completing |
|
| `maxRuns` | `number?` | Max executions before completing |
|
||||||
| `runs` | `ScheduleRun[]` | Execution history |
|
| `runs` | `ScheduleRun[]` | Execution history |
|
||||||
|
|
||||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||||
|
|
||||||
@@ -201,16 +201,16 @@ One file per schedule. ID is 8 hex characters.
|
|||||||
|
|
||||||
### Nested: ScheduleRun
|
### Nested: ScheduleRun
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| -------------- | -------------------------------------- | ----------------------- |
|
||||||
| `id` | `string` | Run ID |
|
| `id` | `string` | Run ID |
|
||||||
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
| `endedAt` | `string?` (ISO 8601) | |
|
| `endedAt` | `string?` (ISO 8601) | |
|
||||||
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
||||||
| `agentId` | `string?` (UUID) | Agent used for this run |
|
| `agentId` | `string?` (UUID) | Agent used for this run |
|
||||||
| `output` | `string?` | Agent output text |
|
| `output` | `string?` | Agent output text |
|
||||||
| `error` | `string?` | Error message if failed |
|
| `error` | `string?` | Error message if failed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -229,25 +229,25 @@ Single file containing all rooms and messages.
|
|||||||
|
|
||||||
### ChatRoom
|
### ChatRoom
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ----------- | ------------------- | ----------------------------------- |
|
||||||
| `id` | `string` (UUID) | |
|
| `id` | `string` (UUID) | |
|
||||||
| `name` | `string` | Unique room name (case-insensitive) |
|
| `name` | `string` | Unique room name (case-insensitive) |
|
||||||
| `purpose` | `string?` | Room description |
|
| `purpose` | `string?` | Room description |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||||
|
|
||||||
### ChatMessage
|
### ChatMessage
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------------ | ------------------- | ----------------------------------- |
|
||||||
| `id` | `string` (UUID) | |
|
| `id` | `string` (UUID) | |
|
||||||
| `roomId` | `string` | FK to ChatRoom.id |
|
| `roomId` | `string` | FK to ChatRoom.id |
|
||||||
| `authorAgentId` | `string` | Agent ID of the author |
|
| `authorAgentId` | `string` | Agent ID of the author |
|
||||||
| `body` | `string` | Message text (supports `@mentions`) |
|
| `body` | `string` | Message text (supports `@mentions`) |
|
||||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -257,84 +257,84 @@ Single file containing all rooms and messages.
|
|||||||
|
|
||||||
Single file containing an array of all loop records.
|
Single file containing an array of all loop records.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||||
| `id` | `string` | 8-char UUID prefix |
|
| `id` | `string` | 8-char UUID prefix |
|
||||||
| `name` | `string?` | Human-readable name |
|
| `name` | `string?` | Human-readable name |
|
||||||
| `prompt` | `string` | Worker prompt |
|
| `prompt` | `string` | Worker prompt |
|
||||||
| `cwd` | `string` | Working directory |
|
| `cwd` | `string` | Working directory |
|
||||||
| `provider` | `string` | Default provider |
|
| `provider` | `string` | Default provider |
|
||||||
| `model` | `string?` | Default model |
|
| `model` | `string?` | Default model |
|
||||||
| `workerProvider` | `string?` | Override provider for workers |
|
| `workerProvider` | `string?` | Override provider for workers |
|
||||||
| `workerModel` | `string?` | Override model for workers |
|
| `workerModel` | `string?` | Override model for workers |
|
||||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||||
| `verifierModel` | `string?` | Override model for verifiers |
|
| `verifierModel` | `string?` | Override model for verifiers |
|
||||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||||
| `maxIterations` | `number?` | Cap on iterations |
|
| `maxIterations` | `number?` | Cap on iterations |
|
||||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
| `completedAt` | `string?` (ISO 8601) | |
|
| `completedAt` | `string?` (ISO 8601) | |
|
||||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||||
| `iterations` | `LoopIteration[]` | |
|
| `iterations` | `LoopIteration[]` | |
|
||||||
| `logs` | `LoopLogEntry[]` | |
|
| `logs` | `LoopLogEntry[]` | |
|
||||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||||
|
|
||||||
### Nested: LoopIteration
|
### Nested: LoopIteration
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||||
| `index` | `number` | 1-based iteration index |
|
| `index` | `number` | 1-based iteration index |
|
||||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||||
| `failureReason` | `string?` | |
|
| `failureReason` | `string?` | |
|
||||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||||
|
|
||||||
### Nested: LoopLogEntry
|
### Nested: LoopLogEntry
|
||||||
|
|
||||||
| Field | Type |
|
| Field | Type |
|
||||||
|---|---|
|
| ----------- | ---------------------------------------------------- |
|
||||||
| `seq` | `number` (monotonic) |
|
| `seq` | `number` (monotonic) |
|
||||||
| `timestamp` | `string` (ISO 8601) |
|
| `timestamp` | `string` (ISO 8601) |
|
||||||
| `iteration` | `number?` |
|
| `iteration` | `number?` |
|
||||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||||
| `level` | `"info" \| "error"` |
|
| `level` | `"info" \| "error"` |
|
||||||
| `text` | `string` |
|
| `text` | `string` |
|
||||||
|
|
||||||
### Nested: LoopVerifyCheckResult
|
### Nested: LoopVerifyCheckResult
|
||||||
|
|
||||||
| Field | Type |
|
| Field | Type |
|
||||||
|---|---|
|
| ------------- | ------------------- |
|
||||||
| `command` | `string` |
|
| `command` | `string` |
|
||||||
| `exitCode` | `number` |
|
| `exitCode` | `number` |
|
||||||
| `passed` | `boolean` |
|
| `passed` | `boolean` |
|
||||||
| `stdout` | `string` |
|
| `stdout` | `string` |
|
||||||
| `stderr` | `string` |
|
| `stderr` | `string` |
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
### Nested: LoopVerifyPromptResult
|
### Nested: LoopVerifyPromptResult
|
||||||
|
|
||||||
| Field | Type |
|
| Field | Type |
|
||||||
|---|---|
|
| ----------------- | ------------------- |
|
||||||
| `passed` | `boolean` |
|
| `passed` | `boolean` |
|
||||||
| `reason` | `string` |
|
| `reason` | `string` |
|
||||||
| `verifierAgentId` | `string?` |
|
| `verifierAgentId` | `string?` |
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -344,15 +344,15 @@ Single file containing an array of all loop records.
|
|||||||
|
|
||||||
Array of project records.
|
Array of project records.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------- | -------------------- | ------------------------------ |
|
||||||
| `projectId` | `string` | Primary key |
|
| `projectId` | `string` | Primary key |
|
||||||
| `rootPath` | `string` | Filesystem root of the project |
|
| `rootPath` | `string` | Filesystem root of the project |
|
||||||
| `kind` | `"git" \| "non_git"` | |
|
| `kind` | `"git" \| "non_git"` | |
|
||||||
| `displayName` | `string` | |
|
| `displayName` | `string` | |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -362,16 +362,16 @@ Array of project records.
|
|||||||
|
|
||||||
Array of workspace records. A workspace is a specific working directory within a project.
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------- | ----------------------------------------------- | ----------------------- |
|
||||||
| `workspaceId` | `string` | Primary key |
|
| `workspaceId` | `string` | Primary key |
|
||||||
| `projectId` | `string` | FK to Project.projectId |
|
| `projectId` | `string` | FK to Project.projectId |
|
||||||
| `cwd` | `string` | Filesystem path |
|
| `cwd` | `string` | Filesystem path |
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||||
| `displayName` | `string` | |
|
| `displayName` | `string` | |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -417,12 +417,12 @@ Stores binary attachment blobs keyed by attachment ID.
|
|||||||
|
|
||||||
### AttachmentMetadata
|
### AttachmentMetadata
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
| ------------- | --------- | ------------------------------ |
|
||||||
| `id` | `string` | Unique attachment ID |
|
| `id` | `string` | Unique attachment ID |
|
||||||
| `mimeType` | `string` | MIME type |
|
| `mimeType` | `string` | MIME type |
|
||||||
| `storageType` | `string` | Storage backend identifier |
|
| `storageType` | `string` | Storage backend identifier |
|
||||||
| `storageKey` | `string` | Key within the storage backend |
|
| `storageKey` | `string` | Key within the storage backend |
|
||||||
| `createdAt` | `number` | Epoch ms |
|
| `createdAt` | `number` | Epoch ms |
|
||||||
| `fileName` | `string?` | Original filename |
|
| `fileName` | `string?` | Original filename |
|
||||||
| `byteSize` | `number?` | Size in bytes |
|
| `byteSize` | `number?` | Size in bytes |
|
||||||
|
|||||||
+16
-6
@@ -22,10 +22,16 @@ PASEO_HOME=~/.paseo-blue npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||||
|
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
||||||
|
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
|
||||||
|
and `config.json`; actual checkout/worktree directories are not copied.
|
||||||
|
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
|
||||||
|
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
|
||||||
|
|
||||||
### Default ports
|
### Default ports
|
||||||
|
|
||||||
In the main checkout:
|
In the main checkout:
|
||||||
|
|
||||||
- Daemon: `localhost:6767`
|
- Daemon: `localhost:6767`
|
||||||
- Expo app: `localhost:8081`
|
- Expo app: `localhost:8081`
|
||||||
|
|
||||||
@@ -58,13 +64,13 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
|
|||||||
|
|
||||||
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|---|---|
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||||
| `HOST` | Bind host for the service process. |
|
| `HOST` | Bind host for the service process. |
|
||||||
|
|
||||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||||
|
|
||||||
@@ -130,11 +136,13 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
Find an agent by ID:
|
Find an agent by ID:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
Find by content:
|
Find by content:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rg -l "some title text" $PASEO_HOME/agents/
|
rg -l "some title text" $PASEO_HOME/agents/
|
||||||
```
|
```
|
||||||
@@ -144,11 +152,13 @@ rg -l "some title text" $PASEO_HOME/agents/
|
|||||||
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||||
|
|
||||||
**Claude:**
|
**Claude:**
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Codex:**
|
**Codex:**
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||||
```
|
```
|
||||||
|
|||||||
+56
-56
@@ -35,8 +35,8 @@ cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Add two things to `material-file-icons.ts`:
|
3. Add two things to `material-file-icons.ts`:
|
||||||
|
|
||||||
- The SVG string in `SVG_ICONS`:
|
- The SVG string in `SVG_ICONS`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
"icon_name": `<svg ...>...</svg>`,
|
"icon_name": `<svg ...>...</svg>`,
|
||||||
```
|
```
|
||||||
@@ -52,58 +52,58 @@ cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
|||||||
|
|
||||||
53 unique icons covering these extensions:
|
53 unique icons covering these extensions:
|
||||||
|
|
||||||
| Extension(s) | Icon |
|
| Extension(s) | Icon |
|
||||||
|---|---|
|
| ------------------------------------------ | ----------- |
|
||||||
| `ts` | typescript |
|
| `ts` | typescript |
|
||||||
| `tsx` | react_ts |
|
| `tsx` | react_ts |
|
||||||
| `js` | javascript |
|
| `js` | javascript |
|
||||||
| `jsx` | react |
|
| `jsx` | react |
|
||||||
| `py` | python |
|
| `py` | python |
|
||||||
| `go` | go |
|
| `go` | go |
|
||||||
| `rs` | rust |
|
| `rs` | rust |
|
||||||
| `rb` | ruby |
|
| `rb` | ruby |
|
||||||
| `java` | java |
|
| `java` | java |
|
||||||
| `kt` | kotlin |
|
| `kt` | kotlin |
|
||||||
| `c` | c |
|
| `c` | c |
|
||||||
| `cpp` | cpp |
|
| `cpp` | cpp |
|
||||||
| `h` | h |
|
| `h` | h |
|
||||||
| `hpp` | hpp |
|
| `hpp` | hpp |
|
||||||
| `cs` | csharp |
|
| `cs` | csharp |
|
||||||
| `swift` | swift |
|
| `swift` | swift |
|
||||||
| `dart` | dart |
|
| `dart` | dart |
|
||||||
| `ex`, `exs` | elixir |
|
| `ex`, `exs` | elixir |
|
||||||
| `erl` | erlang |
|
| `erl` | erlang |
|
||||||
| `hs` | haskell |
|
| `hs` | haskell |
|
||||||
| `clj` | clojure |
|
| `clj` | clojure |
|
||||||
| `scala` | scala |
|
| `scala` | scala |
|
||||||
| `ml` | ocaml |
|
| `ml` | ocaml |
|
||||||
| `r` | r |
|
| `r` | r |
|
||||||
| `lua` | lua |
|
| `lua` | lua |
|
||||||
| `zig` | zig |
|
| `zig` | zig |
|
||||||
| `nix` | nix |
|
| `nix` | nix |
|
||||||
| `php` | php |
|
| `php` | php |
|
||||||
| `html` | html |
|
| `html` | html |
|
||||||
| `css` | css |
|
| `css` | css |
|
||||||
| `scss` | sass |
|
| `scss` | sass |
|
||||||
| `less` | less |
|
| `less` | less |
|
||||||
| `json` | json |
|
| `json` | json |
|
||||||
| `yml`, `yaml` | yaml |
|
| `yml`, `yaml` | yaml |
|
||||||
| `xml` | xml |
|
| `xml` | xml |
|
||||||
| `toml` | toml |
|
| `toml` | toml |
|
||||||
| `md`, `markdown` | markdown |
|
| `md`, `markdown` | markdown |
|
||||||
| `sql` | database |
|
| `sql` | database |
|
||||||
| `graphql`, `gql` | graphql |
|
| `graphql`, `gql` | graphql |
|
||||||
| `sh`, `bash` | console |
|
| `sh`, `bash` | console |
|
||||||
| `tf` | terraform |
|
| `tf` | terraform |
|
||||||
| `hcl` | hcl |
|
| `hcl` | hcl |
|
||||||
| `vue` | vue |
|
| `vue` | vue |
|
||||||
| `svelte` | svelte |
|
| `svelte` | svelte |
|
||||||
| `astro` | astro |
|
| `astro` | astro |
|
||||||
| `wasm` | webassembly |
|
| `wasm` | webassembly |
|
||||||
| `svg` | svg |
|
| `svg` | svg |
|
||||||
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||||
| `txt` | document |
|
| `txt` | document |
|
||||||
| `conf`, `cfg`, `ini` | settings |
|
| `conf`, `cfg`, `ini` | settings |
|
||||||
| `lock` | lock |
|
| `lock` | lock |
|
||||||
| `groovy` | groovy |
|
| `groovy` | groovy |
|
||||||
| `gradle` | gradle |
|
| `gradle` | gradle |
|
||||||
|
|||||||
+29
-2
@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
|
|||||||
|
|
||||||
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
|
### New Workspace Creation
|
||||||
|
|
||||||
|
The Android workspace-creation regression has a dedicated harness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a short recording that starts after launch/connection/sidebar setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
|
||||||
|
|
||||||
|
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
|
||||||
|
|
||||||
|
- `android-dev-client.yaml`
|
||||||
|
- `connect-direct-if-welcome.yaml`
|
||||||
|
- `open-prepared-project-sidebar.yaml`
|
||||||
|
- `new-workspace-open-from-sidebar.yaml`
|
||||||
|
- `new-workspace-select-codex-gpt54.yaml`
|
||||||
|
- `new-workspace-submit-and-assert-created.yaml`
|
||||||
|
|
||||||
|
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
|
||||||
|
|
||||||
### Inputs that Maestro types into
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
@@ -196,7 +223,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
<Animated.View style={[styles.sidebar, animatedStyle]} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -217,7 +244,7 @@ const { theme } = useUnistyles();
|
|||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
/>
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Each project opens as a workspace. For git projects, the default workspace is th
|
|||||||
### Inside a workspace
|
### Inside a workspace
|
||||||
|
|
||||||
A workspace is a flexible canvas:
|
A workspace is a flexible canvas:
|
||||||
|
|
||||||
- Launch multiple agents side by side in split panes
|
- Launch multiple agents side by side in split panes
|
||||||
- Open terminals alongside agents
|
- Open terminals alongside agents
|
||||||
- Mix and match providers within the same workspace
|
- Mix and match providers within the same workspace
|
||||||
@@ -39,6 +40,7 @@ A workspace is a flexible canvas:
|
|||||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||||
|
|
||||||
This architecture means:
|
This architecture means:
|
||||||
|
|
||||||
- The daemon can run on any machine: laptop, VM, remote server
|
- The daemon can run on any machine: laptop, VM, remote server
|
||||||
- Multiple clients can connect simultaneously
|
- Multiple clients can connect simultaneously
|
||||||
- Agents keep running when you close the app
|
- Agents keep running when you close the app
|
||||||
|
|||||||
+14
-7
@@ -58,10 +58,10 @@ type MyProviderClientOptions = {
|
|||||||
export class MyProviderACPAgentClient extends ACPAgentClient {
|
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||||
constructor(options: MyProviderClientOptions) {
|
constructor(options: MyProviderClientOptions) {
|
||||||
super({
|
super({
|
||||||
provider: "my-provider", // Must match the ID used everywhere else
|
provider: "my-provider", // Must match the ID used everywhere else
|
||||||
logger: options.logger,
|
logger: options.logger,
|
||||||
runtimeSettings: options.runtimeSettings,
|
runtimeSettings: options.runtimeSettings,
|
||||||
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||||
defaultModes: MY_PROVIDER_MODES,
|
defaultModes: MY_PROVIDER_MODES,
|
||||||
capabilities: MY_PROVIDER_CAPABILITIES,
|
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||||
});
|
});
|
||||||
@@ -70,7 +70,7 @@ export class MyProviderACPAgentClient extends ACPAgentClient {
|
|||||||
// Override isAvailable() if the provider needs specific auth/env vars
|
// Override isAvailable() if the provider needs specific auth/env vars
|
||||||
override async isAvailable(): Promise<boolean> {
|
override async isAvailable(): Promise<boolean> {
|
||||||
if (!(await super.isAvailable())) {
|
if (!(await super.isAvailable())) {
|
||||||
return false; // Binary not found
|
return false; // Binary not found
|
||||||
}
|
}
|
||||||
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||||
}
|
}
|
||||||
@@ -212,8 +212,8 @@ export const agentConfigs = {
|
|||||||
provider: "my-provider",
|
provider: "my-provider",
|
||||||
model: "default-model-id",
|
model: "default-model-id",
|
||||||
modes: {
|
modes: {
|
||||||
full: "autonomous", // Mode with no permission prompts
|
full: "autonomous", // Mode with no permission prompts
|
||||||
ask: "default", // Mode that requires permission approval
|
ask: "default", // Mode that requires permission approval
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, AgentTestConfig>;
|
} as const satisfies Record<string, AgentTestConfig>;
|
||||||
@@ -264,8 +264,15 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
|||||||
interface AgentClient {
|
interface AgentClient {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
createSession(
|
||||||
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
config: AgentSessionConfig,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession>;
|
||||||
|
resumeSession(
|
||||||
|
handle: AgentPersistenceHandle,
|
||||||
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession>;
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
isAvailable(): Promise<boolean>;
|
isAvailable(): Promise<boolean>;
|
||||||
// Optional:
|
// Optional:
|
||||||
|
|||||||
+4
-1
@@ -79,7 +79,9 @@ const realSender: EmailSender = { send: sendgrid.send };
|
|||||||
function createTestEmailSender() {
|
function createTestEmailSender() {
|
||||||
const sent: Array<{ to: string; body: string }> = [];
|
const sent: Array<{ to: string; body: string }> = [];
|
||||||
return {
|
return {
|
||||||
send: async (to: string, body: string) => { sent.push({ to, body }); },
|
send: async (to: string, body: string) => {
|
||||||
|
sent.push({ to, body });
|
||||||
|
},
|
||||||
sent,
|
sent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,7 @@ The test output is the source of truth, not your reading of the code.
|
|||||||
## Design for testability
|
## Design for testability
|
||||||
|
|
||||||
If code isn't testable, refactor it. Signs:
|
If code isn't testable, refactor it. Signs:
|
||||||
|
|
||||||
- You want to reach for a mock
|
- You want to reach for a mock
|
||||||
- You can't inject a dependency
|
- You can't inject a dependency
|
||||||
- You need to test private internals
|
- You need to test private internals
|
||||||
|
|||||||
+21
-23
@@ -19,7 +19,7 @@ The important detail: the automatic native path tracks `props.style`. It does no
|
|||||||
Avoid this pattern when the style depends on the theme:
|
Avoid this pattern when the style depends on the theme:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<ScrollView contentContainerStyle={styles.container} />
|
<ScrollView contentContainerStyle={styles.container} />;
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -39,10 +39,8 @@ Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `co
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView contentContainerStyle={styles.contentContainer}>
|
<ScrollView contentContainerStyle={styles.contentContainer}>{children}</ScrollView>
|
||||||
{children}
|
</View>;
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -66,10 +64,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|||||||
|
|
||||||
const ThemedScrollView = withUnistyles(ScrollView);
|
const ThemedScrollView = withUnistyles(ScrollView);
|
||||||
|
|
||||||
<ThemedScrollView
|
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={styles.contentContainer}
|
|
||||||
/>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||||
@@ -80,11 +75,8 @@ The smallest escape hatch is to use `useUnistyles()` and pass an inline value th
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[styles.contentContainer, { backgroundColor: theme.colors.surface0 }]}
|
||||||
styles.contentContainer,
|
/>;
|
||||||
{ backgroundColor: theme.colors.surface0 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||||
@@ -98,8 +90,14 @@ The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a
|
|||||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
.unistyles_j2k2iilhfz {
|
||||||
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
flex: 1 1 0%;
|
||||||
|
background-color: var(--colors-surface0);
|
||||||
|
}
|
||||||
|
.unistyles_j2k2iilhfz > * {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
background-color: var(--colors-surface0);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||||
@@ -113,8 +111,10 @@ Symptoms to watch for:
|
|||||||
Quick confirmation in DevTools console:
|
Quick confirmation in DevTools console:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
[...document.styleSheets]
|
||||||
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
.flatMap((s) => [...(s.cssRules || [])])
|
||||||
|
.map((r) => r.cssText)
|
||||||
|
.filter((t) => t.includes("unistyles") && t.includes("> *"));
|
||||||
```
|
```
|
||||||
|
|
||||||
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||||
@@ -130,9 +130,7 @@ We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-c
|
|||||||
```tsx
|
```tsx
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
<Text style={[styles.title, { color: theme.colors.foreground }]}>
|
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>;
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||||
@@ -170,7 +168,7 @@ Use `useUnistyles()` inside the component instead:
|
|||||||
```tsx
|
```tsx
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||||
@@ -233,4 +231,4 @@ The welcome-screen investigation used this approach to prove the white layer was
|
|||||||
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||||
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||||
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||||
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
|
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/knip/schema.json",
|
||||||
|
"workspaces": {
|
||||||
|
".": {
|
||||||
|
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||||
|
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||||
|
},
|
||||||
|
"packages/server": {
|
||||||
|
"entry": [
|
||||||
|
"src/server/index.ts",
|
||||||
|
"src/server/exports.ts",
|
||||||
|
"src/utils/tool-call-parsers.ts",
|
||||||
|
"src/shared/**/*.ts",
|
||||||
|
"src/client/**/*.ts",
|
||||||
|
"src/server/agent/agent-sdk-types.ts",
|
||||||
|
"src/server/agent/provider-manifest.ts",
|
||||||
|
"scripts/**/*.{ts,mts,mjs,cjs,js}",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.e2e.ts",
|
||||||
|
"src/**/*.e2e.tsx"
|
||||||
|
],
|
||||||
|
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mts,mjs,cjs,js}"]
|
||||||
|
},
|
||||||
|
"packages/app": {
|
||||||
|
"entry": [
|
||||||
|
"index.ts",
|
||||||
|
"app.config.js",
|
||||||
|
"babel.config.js",
|
||||||
|
"app/**/*.{ts,tsx}",
|
||||||
|
"src/**/*.test.{ts,tsx}",
|
||||||
|
"src/**/*.e2e.{ts,tsx}",
|
||||||
|
"src/**/*.native.{ts,tsx}",
|
||||||
|
"e2e/**/*.{ts,tsx}",
|
||||||
|
"playwright.config.{ts,js}",
|
||||||
|
"vitest.config.{ts,js}",
|
||||||
|
"test-stubs/**/*.ts"
|
||||||
|
],
|
||||||
|
"project": ["**/*.{ts,tsx,js,jsx}"],
|
||||||
|
"paths": {
|
||||||
|
"@server/*": ["../server/src/*"]
|
||||||
|
},
|
||||||
|
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
|
||||||
|
},
|
||||||
|
"packages/cli": {
|
||||||
|
"entry": ["src/index.ts", "bin/paseo", "src/**/*.test.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||||
|
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
|
||||||
|
},
|
||||||
|
"packages/relay": {
|
||||||
|
"entry": ["src/index.ts", "src/e2ee.ts", "src/cloudflare-adapter.ts", "src/**/*.test.ts"],
|
||||||
|
"project": ["src/**/*.ts"]
|
||||||
|
},
|
||||||
|
"packages/website": {
|
||||||
|
"entry": ["src/router.tsx", "vite.config.ts", "src/routes/**/*.tsx"],
|
||||||
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
|
"ignore": ["src/routeTree.gen.ts"]
|
||||||
|
},
|
||||||
|
"packages/desktop": {
|
||||||
|
"entry": ["src/main.ts", "src/preload.ts", "src/**/*.test.ts"],
|
||||||
|
"project": ["src/**/*.ts"]
|
||||||
|
},
|
||||||
|
"packages/highlight": {
|
||||||
|
"entry": ["src/index.ts", "src/**/*.test.ts"],
|
||||||
|
"project": ["src/**/*.ts"]
|
||||||
|
},
|
||||||
|
"packages/expo-two-way-audio": {
|
||||||
|
"entry": ["src/index.ts"],
|
||||||
|
"project": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignoreDependencies": [
|
||||||
|
"sherpa-onnx-node",
|
||||||
|
"@playwright/test",
|
||||||
|
"material-icon-theme",
|
||||||
|
"eas-cli",
|
||||||
|
"wait-on",
|
||||||
|
"concurrently",
|
||||||
|
"get-port-cli",
|
||||||
|
"patch-package",
|
||||||
|
"cross-env",
|
||||||
|
"expo-module-scripts",
|
||||||
|
"buffer",
|
||||||
|
"metro-config"
|
||||||
|
],
|
||||||
|
"ignoreBinaries": [
|
||||||
|
"expo-module",
|
||||||
|
"xed",
|
||||||
|
"eas",
|
||||||
|
"playwright",
|
||||||
|
"wrangler",
|
||||||
|
"powershell",
|
||||||
|
"tsx",
|
||||||
|
"vitest",
|
||||||
|
"open"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
jobs:
|
||||||
|
- name: format
|
||||||
|
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||||
|
run: npm run format:check:files -- {staged_files}
|
||||||
|
- name: lint
|
||||||
|
glob: "*.{js,jsx,ts,tsx}"
|
||||||
|
run: npm run lint -- {staged_files}
|
||||||
|
- name: typecheck
|
||||||
|
run: npm run typecheck
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
npmDepsHash = "sha256-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+1228
-665
File diff suppressed because it is too large
Load Diff
+36
-33
@@ -1,7 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.60-beta.1",
|
"version": "0.1.63-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
|
"keywords": [
|
||||||
|
"development",
|
||||||
|
"mcp",
|
||||||
|
"openai",
|
||||||
|
"realtime",
|
||||||
|
"voice",
|
||||||
|
"voice-assistant"
|
||||||
|
],
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
"packages/highlight",
|
"packages/highlight",
|
||||||
@@ -19,14 +38,20 @@
|
|||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||||
|
"prepare": "lefthook install --force",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"format": "biome format --write .",
|
"format": "oxfmt .",
|
||||||
"format:check": "biome format .",
|
"format:files": "oxfmt",
|
||||||
|
"format:check": "oxfmt --check .",
|
||||||
|
"format:check:files": "oxfmt --check",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"lint:fix": "oxlint --fix",
|
||||||
|
"knip": "knip",
|
||||||
"start": "npm run start --workspace=@getpaseo/server",
|
"start": "npm run start --workspace=@getpaseo/server",
|
||||||
"android": "npm run android --workspace=@getpaseo/app",
|
"android": "npm run android --workspace=@getpaseo/app",
|
||||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||||
@@ -66,44 +91,22 @@
|
|||||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.8",
|
"@types/ws": "^8.5.14",
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
|
"lefthook": "^2.1.6",
|
||||||
|
"oxfmt": "0.46.0",
|
||||||
|
"oxlint": "1.61.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"react": "19.1.0",
|
"playwright": "^1.56.1",
|
||||||
"react-dom": "19.1.0",
|
"typescript": "^5.9.3",
|
||||||
"typescript": "^5.9.3"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
|
||||||
"homepage": "https://paseo.sh",
|
|
||||||
"keywords": [
|
|
||||||
"openai",
|
|
||||||
"realtime",
|
|
||||||
"voice",
|
|
||||||
"voice-assistant",
|
|
||||||
"development",
|
|
||||||
"mcp"
|
|
||||||
],
|
|
||||||
"author": {
|
|
||||||
"name": "Mohamed Boudra",
|
|
||||||
"email": "hello@moboudra.com"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/getpaseo/paseo.git"
|
|
||||||
},
|
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
||||||
"expo": "~54.0.33",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-native": "0.81.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ jobs:
|
|||||||
params:
|
params:
|
||||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||||
profile: production
|
profile: production
|
||||||
|
|
||||||
|
submit_ios_for_review:
|
||||||
|
name: Submit iOS for App Store review
|
||||||
|
needs: [submit_ios]
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- uses: eas/checkout
|
||||||
|
- name: Install fastlane
|
||||||
|
working_directory: ./packages/app
|
||||||
|
run: bundle install
|
||||||
|
- name: Submit for review
|
||||||
|
working_directory: ./packages/app
|
||||||
|
run: bundle exec fastlane ios submit_review
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
||||||
@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
archiveAgentFromSessions,
|
archiveAgentFromSessions,
|
||||||
|
clickSessionRow,
|
||||||
|
closeWorkspaceAgentTab,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
|
expectArchivedAgentFocused,
|
||||||
|
expectSessionRowArchived,
|
||||||
expectSessionRowVisible,
|
expectSessionRowVisible,
|
||||||
expectWorkspaceArchiveOutcome,
|
expectWorkspaceArchiveOutcome,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
await passivePage.close();
|
await passivePage.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await closeWorkspaceAgentTab(page, archived.id);
|
||||||
|
await archiveAgentFromDaemon(client, archived.id);
|
||||||
|
await openSessions(page);
|
||||||
|
await expectSessionRowArchived(page, archived.title);
|
||||||
|
|
||||||
|
await clickSessionRow(page, archived.title);
|
||||||
|
|
||||||
|
await expectArchivedAgentFocused(page, archived.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r
|
|||||||
|
|
||||||
// Extend base test to provide dynamic baseURL from global-setup
|
// Extend base test to provide dynamic baseURL from global-setup
|
||||||
const test = base.extend({
|
const test = base.extend({
|
||||||
baseURL: async ({}, use) => {
|
baseURL: async ({}, provide) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||||
}
|
}
|
||||||
await use(`http://localhost:${metroPort}`);
|
await provide(`http://localhost:${metroPort}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce }) => {
|
({ daemon, preferences, seedNonce: nonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
@@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
if (disableValue) {
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
if (disableValue === seedNonce) {
|
if (disableValue === nonce) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
|||||||
+348
-244
@@ -6,14 +6,15 @@ import path from "node:path";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
interface WaitForServerOptions {
|
||||||
host?: string;
|
host?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
label: string;
|
label: string;
|
||||||
childProcess?: ChildProcess | null;
|
childProcess?: ChildProcess | null;
|
||||||
getRecentOutput?: () => string;
|
getRecentOutput?: () => string;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -124,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
}
|
}
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
let pendingResolve: (() => void) | null = resolve;
|
||||||
|
const settle = () => {
|
||||||
|
if (!pendingResolve) return;
|
||||||
|
const fn = pendingResolve;
|
||||||
|
pendingResolve = null;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
resolve();
|
settle();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
child.once("exit", () => {
|
child.once("exit", settle);
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +191,23 @@ let paseoHome: string | null = null;
|
|||||||
let fakeGhBinDir: string | null = null;
|
let fakeGhBinDir: string | null = null;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
type OfferPayload = {
|
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed === "current") {
|
||||||
|
return resolvePaseoHomePath("~/.paseo");
|
||||||
|
}
|
||||||
|
return resolvePaseoHomePath(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferPayload {
|
||||||
v: 2;
|
v: 2;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
relay: { endpoint: string };
|
relay: { endpoint: string };
|
||||||
};
|
}
|
||||||
|
|
||||||
async function createFakeGhBin(): Promise<string> {
|
async function createFakeGhBin(): Promise<string> {
|
||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||||
@@ -220,6 +237,11 @@ if (args[0] === "pr" && args[1] === "list") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||||
|
console.error("no pull requests found for branch");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (args[0] === "issue" && args[1] === "list") {
|
if (args[0] === "issue" && args[1] === "list") {
|
||||||
console.log("[]");
|
console.log("[]");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -233,8 +255,10 @@ process.exit(1);
|
|||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
||||||
|
|
||||||
function stripAnsi(input: string): string {
|
function stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
return input.replace(ANSI_PATTERN, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||||
@@ -311,42 +335,44 @@ async function waitForPairingOfferFromCli(args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
interface DictationConfig {
|
||||||
const repoRoot = path.resolve(__dirname, "../../..");
|
openAiUsable: boolean;
|
||||||
ensureRelayBuildArtifact(repoRoot);
|
localModelsDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||||
const envTestPath = path.join(repoRoot, ".env.test");
|
const envTestPath = path.join(repoRoot, ".env.test");
|
||||||
if (existsSync(envTestPath)) {
|
if (existsSync(envTestPath)) {
|
||||||
dotenv.config({ path: envTestPath });
|
dotenv.config({ path: envTestPath });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const port = await getAvailablePort();
|
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
|
||||||
let relayPort = 0;
|
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
|
||||||
const metroPort = await getAvailablePort();
|
if (!forkSourceHome) {
|
||||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
return;
|
||||||
fakeGhBinDir = await createFakeGhBin();
|
}
|
||||||
let relayLineBuffer = createLineBuffer();
|
const forkResult = await forkPaseoHomeMetadata({
|
||||||
const metroLineBuffer = createLineBuffer();
|
sourceHome: forkSourceHome,
|
||||||
const daemonLineBuffer = createLineBuffer();
|
targetHome,
|
||||||
|
});
|
||||||
const cleanup = async () => {
|
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
|
||||||
await Promise.all([
|
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
|
||||||
stopProcess(daemonProcess),
|
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
|
||||||
stopProcess(metroProcess),
|
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
|
||||||
stopProcess(relayProcess),
|
console.log(
|
||||||
]);
|
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
|
||||||
daemonProcess = null;
|
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
|
||||||
metroProcess = null;
|
`${forkResult.copiedBytes} bytes)`,
|
||||||
relayProcess = null;
|
);
|
||||||
if (paseoHome) {
|
if (forkResult.skippedMissing.length > 0) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
console.warn(
|
||||||
paseoHome = null;
|
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
|
||||||
}
|
);
|
||||||
if (fakeGhBinDir) {
|
}
|
||||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
}
|
||||||
fakeGhBinDir = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
const defaultLocalModelsDir = path.join(
|
const defaultLocalModelsDir = path.join(
|
||||||
process.env.HOME ?? "",
|
process.env.HOME ?? "",
|
||||||
@@ -369,213 +395,291 @@ export default async function globalSetup() {
|
|||||||
console.log(
|
console.log(
|
||||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||||
);
|
);
|
||||||
|
return { openAiUsable, localModelsDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelayStreamState {
|
||||||
|
failureLine: string | null;
|
||||||
|
readyForSelectedPort: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRelayStreamHandlers(
|
||||||
|
child: ChildProcess,
|
||||||
|
relayPort: number,
|
||||||
|
buffer: ReturnType<typeof createLineBuffer>,
|
||||||
|
state: RelayStreamState,
|
||||||
|
): void {
|
||||||
|
function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") {
|
||||||
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
buffer.add(`[${streamTag}] ${line}`);
|
||||||
|
const failure = parseRelayStartupFailure(line);
|
||||||
|
if (failure) {
|
||||||
|
state.failureLine = failure;
|
||||||
|
}
|
||||||
|
const clean = stripAnsi(line);
|
||||||
|
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||||
|
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||||
|
state.readyForSelectedPort = true;
|
||||||
|
}
|
||||||
|
if (streamTag === "stdout") {
|
||||||
|
console.log(`[relay] ${line}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[relay] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout"));
|
||||||
|
child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function awaitRelayReady(
|
||||||
|
child: ChildProcess,
|
||||||
|
relayPort: number,
|
||||||
|
state: RelayStreamState,
|
||||||
|
buffer: ReturnType<typeof createLineBuffer>,
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForServer(relayPort, {
|
||||||
|
label: "Relay dev server",
|
||||||
|
timeoutMs: 30000,
|
||||||
|
childProcess: child,
|
||||||
|
getRecentOutput: buffer.dump,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyDeadline = Date.now() + 5000;
|
||||||
|
function isRelayReadyCheckPending(): boolean {
|
||||||
|
if (state.readyForSelectedPort) return false;
|
||||||
|
if (state.failureLine !== null) return false;
|
||||||
|
if (child.exitCode !== null) return false;
|
||||||
|
if (child.signalCode !== null) return false;
|
||||||
|
if (Date.now() >= readyDeadline) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
while (isRelayReadyCheckPending()) await sleep(100);
|
||||||
|
|
||||||
|
if (state.failureLine) {
|
||||||
|
throw new Error(`Relay startup failed: ${state.failureLine}`);
|
||||||
|
}
|
||||||
|
if (!state.readyForSelectedPort) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||||
|
buffer.dump,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput(
|
||||||
|
buffer.dump,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRelay(): Promise<number> {
|
||||||
|
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
|
const maxRelayStartupAttempts = 5;
|
||||||
|
let lastRelayStartupError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||||
|
const relayPort = await getAvailablePort();
|
||||||
|
const buffer = createLineBuffer();
|
||||||
|
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||||
|
|
||||||
|
relayProcess = spawn(
|
||||||
|
"npx",
|
||||||
|
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||||
|
{
|
||||||
|
cwd: relayDir,
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
attachRelayStreamHandlers(relayProcess, relayPort, buffer, state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||||
|
return relayPort;
|
||||||
|
} catch (error) {
|
||||||
|
lastRelayStartupError = error;
|
||||||
|
await stopProcess(relayProcess);
|
||||||
|
relayProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
lastRelayStartupError instanceof Error
|
||||||
|
? lastRelayStartupError.message
|
||||||
|
: String(lastRelayStartupError);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||||
|
const appDir = path.resolve(__dirname, "..");
|
||||||
|
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||||
|
cwd: appDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BROWSER: "none",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => {
|
||||||
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
buffer.add(`[stdout] ${line}`);
|
||||||
|
console.log(`[metro] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
buffer.add(`[stderr] ${line}`);
|
||||||
|
console.error(`[metro] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaemonSpawnArgs {
|
||||||
|
port: number;
|
||||||
|
relayPort: number;
|
||||||
|
metroPort: number;
|
||||||
|
paseoHome: string;
|
||||||
|
fakeGhBinDir: string;
|
||||||
|
dictation: DictationConfig;
|
||||||
|
buffer: ReturnType<typeof createLineBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||||
|
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||||
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
|
const { openAiUsable, localModelsDir } = args.dictation;
|
||||||
|
|
||||||
|
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||||
|
cwd: serverDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
PASEO_HOME: args.paseoHome,
|
||||||
|
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||||
|
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||||
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||||
|
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||||
|
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||||
|
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||||
|
...(openAiUsable
|
||||||
|
? {
|
||||||
|
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
|
NODE_ENV: "development",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutBuffer = "";
|
||||||
|
child.stdout?.on("data", (data: Buffer) => {
|
||||||
|
stdoutBuffer += data.toString("utf8");
|
||||||
|
const lines = stdoutBuffer.split("\n");
|
||||||
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
args.buffer.add(`[stdout] ${trimmed}`);
|
||||||
|
console.log(`[daemon] ${trimmed}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
args.buffer.add(`[stderr] ${line}`);
|
||||||
|
console.error(`[daemon] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
stopProcess(daemonProcess),
|
||||||
|
stopProcess(metroProcess),
|
||||||
|
stopProcess(relayProcess),
|
||||||
|
]);
|
||||||
|
daemonProcess = null;
|
||||||
|
metroProcess = null;
|
||||||
|
relayProcess = null;
|
||||||
|
if (paseoHome && shouldRemovePaseoHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
paseoHome = null;
|
||||||
|
} else if (paseoHome) {
|
||||||
|
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||||
|
}
|
||||||
|
if (fakeGhBinDir) {
|
||||||
|
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||||
|
fakeGhBinDir = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
|
ensureRelayBuildArtifact(repoRoot);
|
||||||
|
await loadEnvTestFile(repoRoot);
|
||||||
|
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const metroPort = await getAvailablePort();
|
||||||
|
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||||
|
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||||
|
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||||
|
fakeGhBinDir = await createFakeGhBin();
|
||||||
|
const metroLineBuffer = createLineBuffer();
|
||||||
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
|
await applyPaseoHomeFork(paseoHome);
|
||||||
|
|
||||||
|
const cleanup = () => performCleanup(shouldRemovePaseoHome);
|
||||||
|
|
||||||
|
const dictation = await resolveDictationConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
const relayPort = await startRelay();
|
||||||
const maxRelayStartupAttempts = 5;
|
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||||
let relayStarted = false;
|
daemonProcess = startDaemon({
|
||||||
let lastRelayStartupError: unknown = null;
|
port,
|
||||||
|
relayPort,
|
||||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
metroPort,
|
||||||
relayPort = await getAvailablePort();
|
paseoHome,
|
||||||
relayLineBuffer = createLineBuffer();
|
fakeGhBinDir,
|
||||||
let relayStartupFailureLine: string | null = null;
|
dictation,
|
||||||
let relayReadyForSelectedPort = false;
|
buffer: daemonLineBuffer,
|
||||||
|
|
||||||
relayProcess = spawn(
|
|
||||||
"npx",
|
|
||||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
|
||||||
{
|
|
||||||
cwd: relayDir,
|
|
||||||
env: { ...process.env },
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
|
||||||
const lines = data
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
relayLineBuffer.add(`[stdout] ${line}`);
|
|
||||||
const failure = parseRelayStartupFailure(line);
|
|
||||||
if (failure) {
|
|
||||||
relayStartupFailureLine = failure;
|
|
||||||
}
|
|
||||||
const clean = stripAnsi(line);
|
|
||||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
|
||||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
|
||||||
relayReadyForSelectedPort = true;
|
|
||||||
}
|
|
||||||
console.log(`[relay] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
relayProcess.stderr?.on("data", (data: Buffer) => {
|
|
||||||
const lines = data
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
relayLineBuffer.add(`[stderr] ${line}`);
|
|
||||||
const failure = parseRelayStartupFailure(line);
|
|
||||||
if (failure) {
|
|
||||||
relayStartupFailureLine = failure;
|
|
||||||
}
|
|
||||||
const clean = stripAnsi(line);
|
|
||||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
|
||||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
|
||||||
relayReadyForSelectedPort = true;
|
|
||||||
}
|
|
||||||
console.error(`[relay] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServer(relayPort, {
|
|
||||||
label: "Relay dev server",
|
|
||||||
timeoutMs: 30000,
|
|
||||||
childProcess: relayProcess,
|
|
||||||
getRecentOutput: relayLineBuffer.dump,
|
|
||||||
});
|
|
||||||
|
|
||||||
const readyDeadline = Date.now() + 5000;
|
|
||||||
while (
|
|
||||||
!relayReadyForSelectedPort &&
|
|
||||||
relayStartupFailureLine === null &&
|
|
||||||
relayProcess?.exitCode === null &&
|
|
||||||
relayProcess?.signalCode === null &&
|
|
||||||
Date.now() < readyDeadline
|
|
||||||
) {
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relayStartupFailureLine) {
|
|
||||||
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
|
||||||
}
|
|
||||||
if (!relayReadyForSelectedPort) {
|
|
||||||
throw new Error(
|
|
||||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
|
||||||
relayLineBuffer.dump,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
|
||||||
throw new Error(
|
|
||||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
|
||||||
relayLineBuffer.dump,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
relayStarted = true;
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
lastRelayStartupError = error;
|
|
||||||
await stopProcess(relayProcess);
|
|
||||||
relayProcess = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relayStarted) {
|
|
||||||
const message =
|
|
||||||
lastRelayStartupError instanceof Error
|
|
||||||
? lastRelayStartupError.message
|
|
||||||
: String(lastRelayStartupError);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start Metro bundler on dynamic port
|
|
||||||
const appDir = path.resolve(__dirname, "..");
|
|
||||||
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
|
||||||
cwd: appDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSER: "none", // Don't auto-open browser
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stdout?.on("data", (data: Buffer) => {
|
|
||||||
const lines = data
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
metroLineBuffer.add(`[stdout] ${line}`);
|
|
||||||
console.log(`[metro] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
metroProcess.stderr?.on("data", (data: Buffer) => {
|
|
||||||
const lines = data
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
metroLineBuffer.add(`[stderr] ${line}`);
|
|
||||||
console.error(`[metro] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
|
||||||
cwd: serverDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
||||||
PASEO_HOME: paseoHome,
|
|
||||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
|
||||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
|
||||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
|
||||||
...(openAiUsable
|
|
||||||
? {
|
|
||||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
|
||||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
|
||||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
|
||||||
NODE_ENV: "development",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdoutBuffer = "";
|
|
||||||
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
|
||||||
stdoutBuffer += data.toString("utf8");
|
|
||||||
const lines = stdoutBuffer.split("\n");
|
|
||||||
stdoutBuffer = lines.pop() ?? "";
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
|
||||||
console.log(`[daemon] ${trimmed}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
|
||||||
const lines = data
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
|
||||||
console.error(`[daemon] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for both daemon and Metro to be ready
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
waitForServer(port, {
|
waitForServer(port, {
|
||||||
label: "Paseo daemon",
|
label: "Paseo daemon",
|
||||||
@@ -584,7 +688,7 @@ export default async function globalSetup() {
|
|||||||
}),
|
}),
|
||||||
waitForServer(metroPort, {
|
waitForServer(metroPort, {
|
||||||
label: "Metro web server",
|
label: "Metro web server",
|
||||||
timeoutMs: 120000, // Metro can take longer to start
|
timeoutMs: 120000,
|
||||||
childProcess: metroProcess,
|
childProcess: metroProcess,
|
||||||
getRecentOutput: metroLineBuffer.dump,
|
getRecentOutput: metroLineBuffer.dump,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
|
|
||||||
export type ScrollMetrics = {
|
export interface ScrollMetrics {
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
distanceFromBottom: number;
|
distanceFromBottom: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type SeededAgent = {
|
export interface SeededAgent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
expectedTailText: string;
|
expectedTailText: string;
|
||||||
url: string;
|
url: string;
|
||||||
workspaceUrl: string;
|
workspaceUrl: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type DaemonClientInstance = {
|
export interface DaemonClientInstance {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
createAgent(options: {
|
createAgent(options: {
|
||||||
@@ -36,7 +36,7 @@ export type DaemonClientInstance = {
|
|||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
function getDaemonWsUrl(): string {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
@@ -84,17 +84,15 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DaemonClientConfig = {
|
interface DaemonClientConfig {
|
||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType: "cli";
|
clientType: "cli";
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||||
config: DaemonClientConfig,
|
|
||||||
) => DaemonClientInstance
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const needsReset = await page.evaluate(
|
const needsReset = await page.evaluate(
|
||||||
({ expectedEndpoint, expectedServerId }) => {
|
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
||||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
if (!raw) return true;
|
if (!raw) return true;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||||
const entry = parsed[0] as any;
|
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
||||||
if (entry?.serverId !== expectedServerId) return true;
|
if (entry?.serverId !== serverId) return true;
|
||||||
const connections = entry?.connections;
|
const connections = entry?.connections;
|
||||||
if (!Array.isArray(connections)) return true;
|
if (!Array.isArray(connections)) return true;
|
||||||
if (
|
if (
|
||||||
connections.some(
|
connections.some(
|
||||||
(c: any) =>
|
(c: { type?: string; endpoint?: string }) =>
|
||||||
c?.type === "directTcp" &&
|
c?.type === "directTcp" &&
|
||||||
typeof c?.endpoint === "string" &&
|
typeof c?.endpoint === "string" &&
|
||||||
/:6767\b/.test(c.endpoint),
|
/:6767\b/.test(c.endpoint),
|
||||||
@@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
)
|
)
|
||||||
return true;
|
return true;
|
||||||
return !connections.some(
|
return !connections.some(
|
||||||
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
(c: { type?: string; endpoint?: string }) =>
|
||||||
|
c?.type === "directTcp" && c?.endpoint === endpoint,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
@@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ daemon, preferences }) => {
|
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
},
|
},
|
||||||
{ daemon, preferences },
|
{ daemon, preferences },
|
||||||
@@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
||||||
|
let registry: unknown;
|
||||||
|
try {
|
||||||
|
registry = JSON.parse(registryRaw);
|
||||||
|
} catch {
|
||||||
|
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
||||||
|
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { serverId: daemon.serverId, connections: daemon.connections };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
||||||
|
if (
|
||||||
|
!Array.isArray(connections) ||
|
||||||
|
!connections.some(
|
||||||
|
(c: { type?: string; endpoint?: string }) =>
|
||||||
|
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Array.isArray(connections) &&
|
||||||
|
connections.some(
|
||||||
|
(c: { type?: string; endpoint?: string }) =>
|
||||||
|
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
||||||
|
try {
|
||||||
|
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||||
|
if (prefs?.serverId !== serverId) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) throw error;
|
||||||
|
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||||
const port = getE2EDaemonPort();
|
const port = getE2EDaemonPort();
|
||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
@@ -98,66 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|||||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry: any;
|
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
||||||
try {
|
if (serverId !== expectedServerId) {
|
||||||
registry = JSON.parse(snapshot.registryRaw);
|
|
||||||
} catch {
|
|
||||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const daemon = registry[0];
|
|
||||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (daemon.serverId !== expectedServerId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections: unknown = daemon?.connections;
|
|
||||||
if (
|
|
||||||
!Array.isArray(connections) ||
|
|
||||||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Array.isArray(connections) &&
|
|
||||||
connections.some(
|
|
||||||
(c: any) =>
|
|
||||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertDaemonConnections(connections, expectedEndpoint);
|
||||||
|
|
||||||
if (!snapshot.prefsRaw) {
|
if (!snapshot.prefsRaw) {
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||||
}
|
}
|
||||||
try {
|
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
|
||||||
if (prefs?.serverId !== daemon.serverId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) throw error;
|
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gotoAppShell = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
@@ -308,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
if (trimmedDirectory.startsWith("/private/var/")) {
|
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
|
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -343,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||||
const registry = JSON.parse(registryRaw) as any[];
|
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
||||||
const prefs = JSON.parse(prefsRaw) as any;
|
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||||
if (!Array.isArray(registry) || registry.length !== 1)
|
if (!Array.isArray(registry) || registry.length !== 1)
|
||||||
return { ok: false, reason: "registry shape" } as const;
|
return { ok: false, reason: "registry shape" } as const;
|
||||||
const serverId = registry[0]?.serverId;
|
const serverId = registry[0]?.serverId;
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export type ArchiveTabAgent = {
|
export interface ArchiveTabAgent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type ArchiveTabDaemonClient = {
|
interface ArchiveTabDaemonClient {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
createAgent(options: {
|
createAgent(options: {
|
||||||
@@ -36,7 +36,7 @@ type ArchiveTabDaemonClient = {
|
|||||||
predicate: (snapshot: { status: string }) => boolean,
|
predicate: (snapshot: { status: string }) => boolean,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
function getDaemonPort(): string {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
@@ -73,17 +73,15 @@ function buildSeededStoragePayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArchiveTabDaemonClientConfig = {
|
interface ArchiveTabDaemonClientConfig {
|
||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType: "cli";
|
clientType: "cli";
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||||
config: ArchiveTabDaemonClientConfig,
|
|
||||||
) => ArchiveTabDaemonClient
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
@@ -150,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise<void> {
|
|||||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
});
|
});
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce }) => {
|
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
|
||||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
if (disableValue) {
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
if (disableValue === seedNonce) {
|
if (disableValue === nonce) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||||
},
|
},
|
||||||
{ daemon, preferences, seedNonce },
|
{ daemon, preferences, seedNonce },
|
||||||
);
|
);
|
||||||
@@ -175,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
|
|||||||
const { daemon, preferences } = buildSeededStoragePayload();
|
const { daemon, preferences } = buildSeededStoragePayload();
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ daemon, preferences }) => {
|
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
},
|
},
|
||||||
{ daemon, preferences },
|
{ daemon, preferences },
|
||||||
@@ -231,6 +229,24 @@ export async function expectWorkspaceArchiveOutcome(
|
|||||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||||
|
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await closeButton.first().click();
|
||||||
|
await expectWorkspaceTabHidden(page, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expectWorkspaceTabVisible(page, agentId);
|
||||||
|
await expect(
|
||||||
|
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||||
@@ -261,6 +277,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
|||||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||||
|
const row = getSessionRowByTitle(page, title);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveAgentFromSessions(
|
export async function archiveAgentFromSessions(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { agentId: string; title: string },
|
input: { agentId: string; title: string },
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
|||||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||||
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
||||||
// No-op: the launcher panel no longer exists.
|
// No-op: the launcher panel no longer exists.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "openProject"
|
| "openProject"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type NewWorkspaceDaemonClientConfig = {
|
interface NewWorkspaceDaemonClientConfig {
|
||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType: "cli";
|
clientType: "cli";
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
};
|
}
|
||||||
|
|
||||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||||
|
|
||||||
export type OpenedProject = {
|
export interface OpenedProject {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
function getDaemonPort(): string {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
@@ -49,9 +49,7 @@ function getDaemonWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
|
||||||
config: NewWorkspaceDaemonClientConfig,
|
|
||||||
) => NewWorkspaceDaemonClient
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import WebSocket from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
type WebSocketLike = {
|
interface WebSocketLike {
|
||||||
readyState: number;
|
readyState: number;
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||||
close: (code?: number, reason?: string) => void;
|
close: (code?: number, reason?: string) => void;
|
||||||
binaryType?: string;
|
binaryType?: string;
|
||||||
on?: (event: string, listener: (...args: any[]) => void) => void;
|
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||||
off?: (event: string, listener: (...args: any[]) => void) => void;
|
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||||
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
|
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||||
addEventListener?: (event: string, listener: (event: any) => void) => void;
|
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||||
removeEventListener?: (event: string, listener: (event: any) => void) => void;
|
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||||
onopen?: ((event: any) => void) | null;
|
onopen?: ((event: unknown) => void) | null;
|
||||||
onclose?: ((event: any) => void) | null;
|
onclose?: ((event: unknown) => void) | null;
|
||||||
onerror?: ((event: any) => void) | null;
|
onerror?: ((event: unknown) => void) | null;
|
||||||
onmessage?: ((event: any) => void) | null;
|
onmessage?: ((event: unknown) => void) | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type NodeWebSocketFactory = (
|
export type NodeWebSocketFactory = (
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface PaseoHomeMetadataForkResult {
|
||||||
|
sourceHome: string;
|
||||||
|
targetHome: string;
|
||||||
|
agentFiles: number;
|
||||||
|
agentBytes: number;
|
||||||
|
projectFiles: number;
|
||||||
|
projectBytes: number;
|
||||||
|
copiedFiles: number;
|
||||||
|
copiedBytes: number;
|
||||||
|
skippedMissing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CopyStats {
|
||||||
|
files: number;
|
||||||
|
bytes: number;
|
||||||
|
skippedMissing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePaseoHomePath(value: string): string {
|
||||||
|
if (value === "~") {
|
||||||
|
return homedir();
|
||||||
|
}
|
||||||
|
if (value.startsWith("~/")) {
|
||||||
|
return path.join(homedir(), value.slice(2));
|
||||||
|
}
|
||||||
|
return path.resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
|
||||||
|
if (!existsSync(sourceDir)) {
|
||||||
|
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||||
|
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = await copyJsonTree(sourcePath, targetPath);
|
||||||
|
stats.files += nested.files;
|
||||||
|
stats.bytes += nested.bytes;
|
||||||
|
stats.skippedMissing.push(...nested.skippedMissing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await copyFile(sourcePath, targetPath);
|
||||||
|
const fileStat = await stat(sourcePath);
|
||||||
|
stats.files += 1;
|
||||||
|
stats.bytes += fileStat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProjectRegistryFiles(
|
||||||
|
sourceHome: string,
|
||||||
|
targetHome: string,
|
||||||
|
): Promise<CopyStats> {
|
||||||
|
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||||
|
const sourceProjectsDir = path.join(sourceHome, "projects");
|
||||||
|
const targetProjectsDir = path.join(targetHome, "projects");
|
||||||
|
await mkdir(targetProjectsDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const fileName of ["projects.json", "workspaces.json"]) {
|
||||||
|
const sourcePath = path.join(sourceProjectsDir, fileName);
|
||||||
|
const targetPath = path.join(targetProjectsDir, fileName);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
stats.skippedMissing.push(sourcePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyFile(sourcePath, targetPath);
|
||||||
|
const fileStat = await stat(sourcePath);
|
||||||
|
stats.files += 1;
|
||||||
|
stats.bytes += fileStat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forkPaseoHomeMetadata(input: {
|
||||||
|
sourceHome: string;
|
||||||
|
targetHome: string;
|
||||||
|
}): Promise<PaseoHomeMetadataForkResult> {
|
||||||
|
const sourceHome = resolvePaseoHomePath(input.sourceHome);
|
||||||
|
const targetHome = resolvePaseoHomePath(input.targetHome);
|
||||||
|
|
||||||
|
if (sourceHome === targetHome) {
|
||||||
|
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(targetHome, { recursive: true });
|
||||||
|
|
||||||
|
// Reset only the copied metadata surface. In particular, do not copy or remove
|
||||||
|
// worktrees here: forked workspace records should continue to point at the
|
||||||
|
// original checkout/worktree paths from the source home.
|
||||||
|
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
|
||||||
|
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
|
||||||
|
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
|
||||||
|
|
||||||
|
const agents = await copyJsonTree(
|
||||||
|
path.join(sourceHome, "agents"),
|
||||||
|
path.join(targetHome, "agents"),
|
||||||
|
);
|
||||||
|
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceHome,
|
||||||
|
targetHome,
|
||||||
|
agentFiles: agents.files,
|
||||||
|
agentBytes: agents.bytes,
|
||||||
|
projectFiles: projects.files,
|
||||||
|
projectBytes: projects.bytes,
|
||||||
|
copiedFiles: agents.files + projects.files,
|
||||||
|
copiedBytes: agents.bytes + projects.bytes,
|
||||||
|
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { expect, type Page } from "../fixtures";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
|
||||||
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||||
|
const E2E_KEY = "@paseo:e2e";
|
||||||
|
|
||||||
|
interface SavedHostInput {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startupScenario(page: Page) {
|
||||||
|
return new StartupScenario(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartupScenario {
|
||||||
|
private readonly page: Page;
|
||||||
|
private savedHosts: SavedHostInput[] = [];
|
||||||
|
private desktopBridge = false;
|
||||||
|
private blockedEndpointPorts = new Set<string>();
|
||||||
|
private viewport: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
withMobileViewport(): this {
|
||||||
|
this.viewport = { width: 390, height: 844 };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withSavedHost(input: SavedHostInput): this {
|
||||||
|
this.savedHosts.push(input);
|
||||||
|
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
||||||
|
if (port) {
|
||||||
|
this.blockedEndpointPorts.add(port);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withPendingDesktopDaemon(): this {
|
||||||
|
this.desktopBridge = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withBlockedPort(port: string): this {
|
||||||
|
this.blockedEndpointPorts.add(port);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openRoot(): Promise<StartupAssertions> {
|
||||||
|
await this.prepare();
|
||||||
|
await this.page.goto("/");
|
||||||
|
return new StartupAssertions(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openHostWorkspace(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<StartupAssertions> {
|
||||||
|
await this.prepare();
|
||||||
|
await this.page.goto(
|
||||||
|
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
||||||
|
);
|
||||||
|
return new StartupAssertions(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepare(): Promise<void> {
|
||||||
|
if (this.viewport) {
|
||||||
|
await this.page.setViewportSize(this.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.desktopBridge) {
|
||||||
|
await installPendingDesktopBridge(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const port of this.blockedEndpointPorts) {
|
||||||
|
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
||||||
|
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.savedHosts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the shared fixture create its seed nonce, then opt out of that seed for
|
||||||
|
// the next navigation so this scenario owns the stored host registry.
|
||||||
|
await this.page.goto("/");
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const registry = this.savedHosts.map((host) =>
|
||||||
|
buildStoredHost({
|
||||||
|
serverId: host.serverId,
|
||||||
|
endpoint: host.endpoint,
|
||||||
|
label: host.label,
|
||||||
|
nowIso,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const firstHost = registry[0];
|
||||||
|
if (!firstHost) {
|
||||||
|
throw new Error("Expected at least one startup test host.");
|
||||||
|
}
|
||||||
|
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
||||||
|
|
||||||
|
await this.page.evaluate(
|
||||||
|
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
||||||
|
const nonce = localStorage.getItem(keys.seedNonce);
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(keys.e2e, "1");
|
||||||
|
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
||||||
|
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: {
|
||||||
|
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||||
|
e2e: E2E_KEY,
|
||||||
|
registry: REGISTRY_KEY,
|
||||||
|
seedNonce: SEED_NONCE_KEY,
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
createAgentPreferences,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartupAssertions {
|
||||||
|
private readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsReconnectWelcome(): Promise<this> {
|
||||||
|
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
||||||
|
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
||||||
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsDesktopDaemonStartup(): Promise<this> {
|
||||||
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsSidebarHidden(): Promise<this> {
|
||||||
|
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
||||||
|
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoUndefinedRoute(): Promise<this> {
|
||||||
|
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||||
|
platform: "darwin",
|
||||||
|
invoke: async (command: string) => {
|
||||||
|
if (command === "start_desktop_daemon") {
|
||||||
|
await new Promise(() => {
|
||||||
|
// Keep the daemon in the startup phase until the test ends.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "desktop_daemon_status") {
|
||||||
|
return {
|
||||||
|
serverId: "srv_desktop_pending",
|
||||||
|
status: "starting",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: null,
|
||||||
|
home: "",
|
||||||
|
version: null,
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "desktop_daemon_logs") {
|
||||||
|
return { logPath: "", contents: "" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getPendingOpenProject: async () => null,
|
||||||
|
events: { on: async () => () => undefined },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredHost(input: {
|
||||||
|
serverId: string;
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
nowIso: string;
|
||||||
|
}) {
|
||||||
|
return buildSeededHost(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||||
|
return buildCreateAgentPreferences(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./terminal-perf";
|
||||||
|
|
||||||
|
interface TempRepo {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalInstance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalE2EHarness {
|
||||||
|
readonly client: TerminalPerfDaemonClient;
|
||||||
|
readonly tempRepo: TempRepo;
|
||||||
|
readonly workspaceId: string;
|
||||||
|
|
||||||
|
private constructor(input: {
|
||||||
|
client: TerminalPerfDaemonClient;
|
||||||
|
tempRepo: TempRepo;
|
||||||
|
workspaceId: string;
|
||||||
|
}) {
|
||||||
|
this.client = input.client;
|
||||||
|
this.tempRepo = input.tempRepo;
|
||||||
|
this.workspaceId = input.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||||
|
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||||
|
const client = await connectTerminalClient();
|
||||||
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
|
if (!seedResult.workspace) {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
await tempRepo.cleanup().catch(() => {});
|
||||||
|
throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||||
|
}
|
||||||
|
return new TerminalE2EHarness({
|
||||||
|
client,
|
||||||
|
tempRepo,
|
||||||
|
workspaceId: seedResult.workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
await this.client.close().catch(() => {});
|
||||||
|
await this.tempRepo.cleanup().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
||||||
|
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
return result.terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async killTerminal(terminalId: string): Promise<void> {
|
||||||
|
await this.client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
|
||||||
|
await navigateToTerminal(page, {
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalSurface(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
await setupDeterministicPrompt(page, sentinel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTerminalInApp<T>(
|
||||||
|
page: Page,
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { name: string },
|
||||||
|
fn: (terminal: TerminalInstance) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const terminal = await harness.createTerminal({ name: input.name });
|
||||||
|
try {
|
||||||
|
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||||
|
return await fn(terminal);
|
||||||
|
} finally {
|
||||||
|
await harness.killTerminal(terminal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
export type TerminalPerfDaemonClient = {
|
export interface TerminalPerfDaemonClient {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
openProject(cwd: string): Promise<{
|
openProject(cwd: string): Promise<{
|
||||||
@@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = {
|
|||||||
terminal: { id: string; name: string; cwd: string } | null;
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
createAgent(options: {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
title?: string;
|
||||||
|
modeId?: string;
|
||||||
|
}): Promise<{ id: string; status: string }>;
|
||||||
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
@@ -30,7 +37,7 @@ export type TerminalPerfDaemonClient = {
|
|||||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
): () => void;
|
): () => void;
|
||||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
function getDaemonWsUrl(): string {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
@@ -48,17 +55,15 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalPerfDaemonClientConfig = {
|
interface TerminalPerfDaemonClientConfig {
|
||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType: "cli";
|
clientType: "cli";
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
};
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
|
||||||
config: TerminalPerfDaemonClientConfig,
|
|
||||||
) => TerminalPerfDaemonClient
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
@@ -95,7 +100,19 @@ function buildWorkspaceUrl(workspaceId: string): string {
|
|||||||
|
|
||||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const term = (window as any).__paseoTerminal;
|
const term = (
|
||||||
|
window as Window & {
|
||||||
|
__paseoTerminal?: {
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
length: number;
|
||||||
|
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
onWriteParsed: (cb: () => void) => { dispose: () => void };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__paseoTerminal;
|
||||||
if (!term) {
|
if (!term) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -186,10 +203,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LatencySample = {
|
export interface LatencySample {
|
||||||
char: string;
|
char: string;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measures keystroke echo round-trip latency.
|
* Measures keystroke echo round-trip latency.
|
||||||
@@ -200,12 +217,16 @@ export type LatencySample = {
|
|||||||
*/
|
*/
|
||||||
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const term = (window as any).__paseoTerminal;
|
const win = window as Window & {
|
||||||
if (!term) {
|
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
|
||||||
|
__perfKeystroke?: { promise: Promise<number> | null };
|
||||||
|
};
|
||||||
|
if (!win.__paseoTerminal) {
|
||||||
throw new Error("__paseoTerminal not available");
|
throw new Error("__paseoTerminal not available");
|
||||||
}
|
}
|
||||||
|
const term = win.__paseoTerminal;
|
||||||
|
|
||||||
const state = ((window as any).__perfKeystroke = {
|
const state = (win.__perfKeystroke = {
|
||||||
promise: null as Promise<number> | null,
|
promise: null as Promise<number> | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +252,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
|||||||
|
|
||||||
await page.keyboard.press(char);
|
await page.keyboard.press(char);
|
||||||
|
|
||||||
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
|
||||||
|
.promise,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computePercentile(samples: number[], p: number): number {
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export interface TerminalRenderProbeSnapshot {
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalRenderProbeEvent {
|
||||||
|
at: number;
|
||||||
|
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalFrame {
|
||||||
|
at: number;
|
||||||
|
rowCount: number;
|
||||||
|
nonEmptyRows: number;
|
||||||
|
firstNonEmptyRow: number | null;
|
||||||
|
text: string;
|
||||||
|
topText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
|
||||||
|
frameCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TerminalKeystrokeStressReport {
|
||||||
|
inputTextLength: number;
|
||||||
|
keydownCount: number;
|
||||||
|
inputFrameCount: number;
|
||||||
|
outputFrameCount: number;
|
||||||
|
textMessageFrameCount: number;
|
||||||
|
textMessagePayloadBytes: number;
|
||||||
|
largeTextMessageCount: number;
|
||||||
|
largestTextMessageBytes: number;
|
||||||
|
agentStreamTextMessageCount: number;
|
||||||
|
agentStreamTextMessagePayloadBytes: number;
|
||||||
|
largeAgentStreamTextMessageCount: number;
|
||||||
|
largestAgentStreamTextMessageBytes: number;
|
||||||
|
appEventCount: number;
|
||||||
|
appEventCounts: Record<string, number>;
|
||||||
|
runtimeMaxQueueDepth: number;
|
||||||
|
xtermWriteCount: number;
|
||||||
|
inputFramePayloadBytes: number;
|
||||||
|
outputFramePayloadBytes: number;
|
||||||
|
keydownToInputFrameMs: LatencyStats | null;
|
||||||
|
inputFrameToOutputFrameMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
|
||||||
|
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
|
||||||
|
appTerminalEmitListenerDurationMs: LatencyStats | null;
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
|
||||||
|
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
|
||||||
|
outputFrameToXtermWriteMs: LatencyStats | null;
|
||||||
|
xtermWriteDurationMs: LatencyStats | null;
|
||||||
|
keydownToXtermCommitMs: LatencyStats | null;
|
||||||
|
firstKeydownAt: number | null;
|
||||||
|
lastXtermCommitAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatencyStats {
|
||||||
|
count: number;
|
||||||
|
minMs: number;
|
||||||
|
p50Ms: number;
|
||||||
|
p95Ms: number;
|
||||||
|
maxMs: number;
|
||||||
|
avgMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
interface ProbeTerm {
|
||||||
|
write?: (data: string | Uint8Array, callback?: () => void) => void;
|
||||||
|
__paseoRenderProbeWriteWrapped?: boolean;
|
||||||
|
}
|
||||||
|
interface ProbeState {
|
||||||
|
term: ProbeTerm | undefined;
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
rafId: number | null;
|
||||||
|
sampleUntil: number;
|
||||||
|
reset: () => void;
|
||||||
|
snapshot: () => TerminalRenderProbeSnapshot;
|
||||||
|
startSampling: (durationMs: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as unknown as Record<string, unknown> & {
|
||||||
|
__terminalRenderProbe?: ProbeState;
|
||||||
|
__paseoTerminal?: ProbeTerm;
|
||||||
|
};
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
const probe: ProbeState = {
|
||||||
|
term: getExisting(),
|
||||||
|
setCount: 0,
|
||||||
|
unsetCount: 0,
|
||||||
|
writeCount: 0,
|
||||||
|
resetWrites: 0,
|
||||||
|
clearScreenWrites: 0,
|
||||||
|
altEnterWrites: 0,
|
||||||
|
altExitWrites: 0,
|
||||||
|
events: [],
|
||||||
|
frames: [],
|
||||||
|
rafId: null,
|
||||||
|
sampleUntil: 0,
|
||||||
|
reset() {
|
||||||
|
this.setCount = 0;
|
||||||
|
this.unsetCount = 0;
|
||||||
|
this.writeCount = 0;
|
||||||
|
this.resetWrites = 0;
|
||||||
|
this.clearScreenWrites = 0;
|
||||||
|
this.altEnterWrites = 0;
|
||||||
|
this.altExitWrites = 0;
|
||||||
|
this.events = [];
|
||||||
|
this.frames = [];
|
||||||
|
},
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
setCount: this.setCount,
|
||||||
|
unsetCount: this.unsetCount,
|
||||||
|
writeCount: this.writeCount,
|
||||||
|
resetWrites: this.resetWrites,
|
||||||
|
clearScreenWrites: this.clearScreenWrites,
|
||||||
|
altEnterWrites: this.altEnterWrites,
|
||||||
|
altExitWrites: this.altExitWrites,
|
||||||
|
events: this.events,
|
||||||
|
frames: this.frames,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
startSampling(durationMs: number) {
|
||||||
|
this.sampleUntil = performance.now() + durationMs;
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
}
|
||||||
|
const sample = () => {
|
||||||
|
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
|
||||||
|
(row) => row.textContent ?? "",
|
||||||
|
);
|
||||||
|
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
|
||||||
|
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
|
||||||
|
this.frames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
rowCount: rows.length,
|
||||||
|
nonEmptyRows: nonEmptyRows.length,
|
||||||
|
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
|
||||||
|
text: rows.join("\n"),
|
||||||
|
topText: rows.slice(0, 3).join("\n"),
|
||||||
|
});
|
||||||
|
if (performance.now() < this.sampleUntil) {
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
} else {
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__terminalRenderProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return probe.term;
|
||||||
|
},
|
||||||
|
set(next: ProbeTerm | undefined) {
|
||||||
|
if (next === undefined) {
|
||||||
|
probe.unsetCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "unset" });
|
||||||
|
probe.term = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
probe.setCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "set" });
|
||||||
|
probe.term = next;
|
||||||
|
|
||||||
|
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
probe.writeCount += 1;
|
||||||
|
const preview = text
|
||||||
|
.replaceAll("\u001b", "\\x1b")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.slice(0, 160);
|
||||||
|
if (text.includes("\u001bc")) {
|
||||||
|
probe.resetWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "reset-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[2J")) {
|
||||||
|
probe.clearScreenWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "clear-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049h")) {
|
||||||
|
probe.altEnterWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049l")) {
|
||||||
|
probe.altExitWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
|
||||||
|
}
|
||||||
|
return originalWrite(data, callback);
|
||||||
|
};
|
||||||
|
next.__paseoRenderProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalRenderProbeWindow {
|
||||||
|
__terminalRenderProbe: {
|
||||||
|
reset: () => void;
|
||||||
|
startSampling: (durationMs: number) => void;
|
||||||
|
snapshot: () => TerminalRenderProbeSnapshot;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
|
||||||
|
await page.evaluate((ms) => {
|
||||||
|
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalVisibleText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll(".xterm-rows > div"))
|
||||||
|
.map((row) => row.textContent ?? "")
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeTerminalRenderProbe(
|
||||||
|
probe: TerminalRenderProbeSnapshot,
|
||||||
|
): TerminalRenderProbeSummary {
|
||||||
|
return {
|
||||||
|
setCount: probe.setCount,
|
||||||
|
unsetCount: probe.unsetCount,
|
||||||
|
writeCount: probe.writeCount,
|
||||||
|
resetWrites: probe.resetWrites,
|
||||||
|
clearScreenWrites: probe.clearScreenWrites,
|
||||||
|
altEnterWrites: probe.altEnterWrites,
|
||||||
|
altExitWrites: probe.altExitWrites,
|
||||||
|
events: probe.events,
|
||||||
|
frameCount: probe.frames.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
interface TimedTextEvent {
|
||||||
|
at: number;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
interface TimedTextMessageEvent {
|
||||||
|
at: number;
|
||||||
|
bytes: number;
|
||||||
|
kind: string | null;
|
||||||
|
}
|
||||||
|
interface XtermWriteEvent {
|
||||||
|
at: number;
|
||||||
|
committedAt: number | null;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
interface AppProbeEvent {
|
||||||
|
type: string;
|
||||||
|
at: number;
|
||||||
|
bytes?: number;
|
||||||
|
queueDepth?: number;
|
||||||
|
}
|
||||||
|
interface StressProbeState {
|
||||||
|
keydowns: Array<{ at: number; key: string }>;
|
||||||
|
inputFrames: TimedTextEvent[];
|
||||||
|
outputFrames: TimedTextEvent[];
|
||||||
|
textMessageFrames: TimedTextMessageEvent[];
|
||||||
|
xtermWrites: XtermWriteEvent[];
|
||||||
|
appEvents: AppProbeEvent[];
|
||||||
|
reset: () => void;
|
||||||
|
report: (inputText: string) => TerminalKeystrokeStressReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_OPCODE = 0x02;
|
||||||
|
const OUTPUT_OPCODE = 0x01;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesFrom(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes) return Promise.resolve(bytes);
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameText(bytes: Uint8Array): string {
|
||||||
|
return decoder.decode(bytes.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataText(data: unknown): Promise<string | null> {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.text();
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMessageKind(text: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: {
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (typeof parsed.type !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed.message?.type === "string") {
|
||||||
|
return `${parsed.type}:${parsed.message.type}`;
|
||||||
|
}
|
||||||
|
return parsed.type;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(values: number[]): LatencyStats | null {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const percentile = (p: number) => {
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
||||||
|
};
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
minMs: round2(sorted[0] ?? 0),
|
||||||
|
p50Ms: round2(percentile(50)),
|
||||||
|
p95Ms: round2(percentile(95)),
|
||||||
|
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||||
|
avgMs: round2(total / values.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
|
||||||
|
return events.find((event) => event.at >= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
|
||||||
|
return (
|
||||||
|
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByType(events: AppProbeEvent[]): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const event of events) {
|
||||||
|
counts[event.type] = (counts[event.type] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
|
||||||
|
return events.filter((event) => event.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
|
||||||
|
const count = Math.min(from.length, to.length);
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
values.push(to[index]!.at - from[index]!.at);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe: StressProbeState = {
|
||||||
|
keydowns: [],
|
||||||
|
inputFrames: [],
|
||||||
|
outputFrames: [],
|
||||||
|
textMessageFrames: [],
|
||||||
|
xtermWrites: [],
|
||||||
|
appEvents: [],
|
||||||
|
reset() {
|
||||||
|
this.keydowns = [];
|
||||||
|
this.inputFrames = [];
|
||||||
|
this.outputFrames = [];
|
||||||
|
this.textMessageFrames = [];
|
||||||
|
this.xtermWrites = [];
|
||||||
|
this.appEvents = [];
|
||||||
|
},
|
||||||
|
report(inputText: string) {
|
||||||
|
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
|
||||||
|
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
|
||||||
|
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
|
||||||
|
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
|
||||||
|
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
|
||||||
|
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
|
||||||
|
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
|
||||||
|
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
|
||||||
|
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
|
||||||
|
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
|
||||||
|
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
|
||||||
|
const keydownToInputFrame = this.keydowns
|
||||||
|
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||||
|
.filter((at): at is number => at !== null)
|
||||||
|
.map((at, index) => at - this.keydowns[index]!.at);
|
||||||
|
const inputFrameToOutputFrame = this.inputFrames
|
||||||
|
.map((input) => {
|
||||||
|
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||||
|
return output ? output.at - input.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const outputFrameToXtermWrite = this.outputFrames
|
||||||
|
.map((output) => {
|
||||||
|
const write = firstAtOrAfter(this.xtermWrites, output.at);
|
||||||
|
return write ? write.at - output.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const xtermWriteDurations = this.xtermWrites
|
||||||
|
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const keydownToXtermCommit = this.keydowns
|
||||||
|
.map((keydown) => {
|
||||||
|
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
|
||||||
|
return write?.committedAt ? write.committedAt - keydown.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputTextLength: inputText.length,
|
||||||
|
keydownCount: this.keydowns.length,
|
||||||
|
inputFrameCount: this.inputFrames.length,
|
||||||
|
outputFrameCount: this.outputFrames.length,
|
||||||
|
textMessageFrameCount: this.textMessageFrames.length,
|
||||||
|
textMessagePayloadBytes: this.textMessageFrames.reduce(
|
||||||
|
(sum, frame) => sum + frame.bytes,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
|
||||||
|
.length,
|
||||||
|
largestTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
agentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream",
|
||||||
|
).length,
|
||||||
|
agentStreamTextMessagePayloadBytes: this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
|
||||||
|
).length,
|
||||||
|
largestAgentStreamTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
appEventCount: this.appEvents.length,
|
||||||
|
appEventCounts: countByType(this.appEvents),
|
||||||
|
runtimeMaxQueueDepth: Math.max(
|
||||||
|
0,
|
||||||
|
...this.appEvents
|
||||||
|
.map((event) => event.queueDepth)
|
||||||
|
.filter((value): value is number => typeof value === "number"),
|
||||||
|
),
|
||||||
|
xtermWriteCount: this.xtermWrites.length,
|
||||||
|
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
keydownToInputFrameMs: summarize(keydownToInputFrame),
|
||||||
|
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
|
||||||
|
appBinaryReceivedToFrameDecodedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, frameDecoded),
|
||||||
|
),
|
||||||
|
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
|
||||||
|
appTerminalEmitListenerDurationMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, terminalEmitted),
|
||||||
|
),
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, streamControllerOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerDecodeToOnOutputMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerToEmulatorWriteMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
|
||||||
|
),
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: summarize(
|
||||||
|
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: summarize(
|
||||||
|
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
|
||||||
|
),
|
||||||
|
appRuntimeXtermWriteToCommitMs: summarize(
|
||||||
|
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToXtermCommitMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
|
||||||
|
xtermWriteDurationMs: summarize(xtermWriteDurations),
|
||||||
|
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
|
||||||
|
firstKeydownAt: this.keydowns[0]?.at ?? null,
|
||||||
|
lastXtermCommitAt:
|
||||||
|
this.xtermWrites
|
||||||
|
.map((write) => write.committedAt)
|
||||||
|
.findLast((at): at is number => typeof at === "number") ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
probe.keydowns.push({ at: performance.now(), key: event.key });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativeWebSocket = window.WebSocket;
|
||||||
|
class InstrumentedWebSocket extends NativeWebSocket {
|
||||||
|
constructor(url: string | URL, protocols?: string | string[]) {
|
||||||
|
if (protocols === undefined) {
|
||||||
|
super(url);
|
||||||
|
} else {
|
||||||
|
super(url, protocols);
|
||||||
|
}
|
||||||
|
super.addEventListener("message", (event) => {
|
||||||
|
void eventDataBytes(event.data).then((bytes) => {
|
||||||
|
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.outputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
void eventDataText(event.data).then((text) => {
|
||||||
|
if (text === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.textMessageFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
bytes: new TextEncoder().encode(text).byteLength,
|
||||||
|
kind: textMessageKind(text),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: Parameters<WebSocket["send"]>[0]): void {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
|
||||||
|
probe.inputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
|
||||||
|
value: NativeWebSocket.CONNECTING,
|
||||||
|
});
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
|
||||||
|
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
||||||
|
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
let terminal = getExisting();
|
||||||
|
Object.defineProperty(window, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return terminal;
|
||||||
|
},
|
||||||
|
set(next: {
|
||||||
|
write?: (data: string | Uint8Array, callback?: () => void) => void;
|
||||||
|
__paseoKeystrokeProbeWriteWrapped?: boolean;
|
||||||
|
}) {
|
||||||
|
terminal = next;
|
||||||
|
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
const event: XtermWriteEvent = {
|
||||||
|
at: performance.now(),
|
||||||
|
committedAt: null,
|
||||||
|
text,
|
||||||
|
bytes: text.length,
|
||||||
|
};
|
||||||
|
probe.xtermWrites.push(event);
|
||||||
|
return originalWrite(data, () => {
|
||||||
|
event.committedAt = performance.now();
|
||||||
|
callback?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
next.__paseoKeystrokeProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalKeystrokeStressProbeWindow {
|
||||||
|
__terminalKeystrokeStressProbe: {
|
||||||
|
reset: () => void;
|
||||||
|
report: (text: string) => TerminalKeystrokeStressReport;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(
|
||||||
|
window as unknown as TerminalKeystrokeStressProbeWindow
|
||||||
|
).__terminalKeystrokeStressProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalKeystrokeStressReport(
|
||||||
|
page: Page,
|
||||||
|
inputText: string,
|
||||||
|
): Promise<TerminalKeystrokeStressReport> {
|
||||||
|
return page.evaluate(
|
||||||
|
(text) =>
|
||||||
|
(
|
||||||
|
window as unknown as TerminalKeystrokeStressProbeWindow
|
||||||
|
).__terminalKeystrokeStressProbe.report(text),
|
||||||
|
inputText,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { gotoAppShell } from "./app";
|
|||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
type WorkspaceSetupDaemonClient = {
|
interface WorkspaceSetupDaemonClient {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
openProject(cwd: string): Promise<{
|
openProject(cwd: string): Promise<{
|
||||||
@@ -52,7 +52,7 @@ type WorkspaceSetupDaemonClient = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}>;
|
}>;
|
||||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type WorkspaceSetupProgressPayload = Extract<
|
export type WorkspaceSetupProgressPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -116,7 +116,7 @@ export async function seedProjectForWorkspaceSetup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function projectNameFromPath(repoPath: string): string {
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type TempRepo = {
|
interface TempRepo {
|
||||||
path: string;
|
path: string;
|
||||||
branchHeads: Record<string, string>;
|
branchHeads: Record<string, string>;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from "./helpers/launcher";
|
} from "./helpers/launcher";
|
||||||
import {
|
import {
|
||||||
connectTerminalClient,
|
connectTerminalClient,
|
||||||
waitForTerminalContent,
|
|
||||||
setupDeterministicPrompt,
|
setupDeterministicPrompt,
|
||||||
type TerminalPerfDaemonClient,
|
type TerminalPerfDaemonClient,
|
||||||
} from "./helpers/terminal-perf";
|
} from "./helpers/terminal-perf";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
@@ -28,6 +28,108 @@ import {
|
|||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = input[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||||
|
const createRequestIds = new Set<string>();
|
||||||
|
const delayedForwards: Array<() => void> = [];
|
||||||
|
let releaseRequested = false;
|
||||||
|
let resolveCreateRequest: (() => void) | null = null;
|
||||||
|
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||||
|
const createRequestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveCreateRequest = resolve;
|
||||||
|
});
|
||||||
|
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayedCreatedStatus = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
const sessionMessage = getSessionMessage(message);
|
||||||
|
if (sessionMessage?.type === "create_agent_request") {
|
||||||
|
const requestId = getStringField(sessionMessage, "requestId");
|
||||||
|
if (requestId) {
|
||||||
|
createRequestIds.add(requestId);
|
||||||
|
resolveCreateRequest?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.onMessage((message) => {
|
||||||
|
const sessionMessage = getSessionMessage(message);
|
||||||
|
const payload =
|
||||||
|
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||||
|
? (sessionMessage.payload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||||
|
|
||||||
|
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||||
|
resolveDelayedCreatedStatus?.();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForCreateRequest: () => createRequestSeen,
|
||||||
|
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("New workspace flow", () => {
|
test.describe("New workspace flow", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
@@ -191,7 +293,7 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
|
|||||||
await agentTabs.first().click();
|
await agentTabs.first().click();
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||||
|
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: openedProject.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: openedProject.workspaceName,
|
||||||
|
subtitle: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
await agentCreatedDelay.waitForCreateRequest();
|
||||||
|
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||||
|
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { expect, test, type Page } from "./fixtures";
|
||||||
|
import {
|
||||||
|
archiveAgentFromDaemon,
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
openWorkspaceWithAgents,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressSettingsToggleShortcut(page: Page) {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
|
||||||
|
})
|
||||||
|
.toBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAgentRouteAndExpectFocused(input: {
|
||||||
|
page: Page;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
agentId: string;
|
||||||
|
}) {
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||||
|
await input.page.goto(
|
||||||
|
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||||
|
);
|
||||||
|
await input.page.waitForURL(
|
||||||
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await waitForTabBar(input.page);
|
||||||
|
await expect(
|
||||||
|
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Settings toggle tab regression", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("toggling settings after changing a setting returns to the same workspace tab", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const client = await connectArchiveTabDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("settings-toggle-tab-");
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `settings-toggle-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `settings-toggle-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
agentIds.push(firstAgent.id, secondAgent.id);
|
||||||
|
|
||||||
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
||||||
|
await expectSendBehavior(page, "queue");
|
||||||
|
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
||||||
|
await expectSendBehavior(page, "interrupt");
|
||||||
|
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
} finally {
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const client = await connectArchiveTabDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("agent-route-refresh-");
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `agent-route-refresh-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `agent-route-refresh-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
agentIds.push(firstAgent.id, secondAgent.id);
|
||||||
|
|
||||||
|
await openAgentRouteAndExpectFocused({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: repo.path,
|
||||||
|
agentId: firstAgent.id,
|
||||||
|
});
|
||||||
|
await openAgentRouteAndExpectFocused({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: repo.path,
|
||||||
|
agentId: secondAgent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
await page.reload();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import { startupScenario } from "./helpers/startup-dsl";
|
||||||
|
|
||||||
|
function getE2EDaemonPort(): string {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getE2EServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Startup loading presentation", () => {
|
||||||
|
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withMobileViewport()
|
||||||
|
.withSavedHost({
|
||||||
|
serverId: "srv_unreachable_mobile",
|
||||||
|
label: "Dev",
|
||||||
|
endpoint: "127.0.0.1:45678",
|
||||||
|
})
|
||||||
|
.openRoot();
|
||||||
|
|
||||||
|
await startup.expectsReconnectWelcome();
|
||||||
|
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
||||||
|
await startup.expectsNoLocalServerStartupCopy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withPendingDesktopDaemon()
|
||||||
|
.withBlockedPort(getE2EDaemonPort())
|
||||||
|
.openRoot();
|
||||||
|
|
||||||
|
await startup.expectsDesktopDaemonStartup();
|
||||||
|
await startup.expectsSidebarHidden();
|
||||||
|
await startup.expectsNoUndefinedRoute();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getE2EServerId();
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withPendingDesktopDaemon()
|
||||||
|
.withBlockedPort(getE2EDaemonPort())
|
||||||
|
.openHostWorkspace({
|
||||||
|
serverId,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await startup.expectsDesktopDaemonStartup();
|
||||||
|
await startup.expectsSidebarHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
type WireDirection = "sent" | "received";
|
||||||
|
type WirePhase = "startup" | "workspace_clicks";
|
||||||
|
|
||||||
|
interface ParsedWireMessage {
|
||||||
|
type: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
entryCount: number | null;
|
||||||
|
hasMore: boolean | null;
|
||||||
|
providerEntries: ProviderSnapshotWireEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WireFrameRecord = ParsedWireMessage & {
|
||||||
|
phase: WirePhase;
|
||||||
|
direction: WireDirection;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderSnapshotWireEntry {
|
||||||
|
provider: string;
|
||||||
|
status: string | null;
|
||||||
|
modelCount: number;
|
||||||
|
modeCount: number;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSocketFrameEvent {
|
||||||
|
requestId: string;
|
||||||
|
response: {
|
||||||
|
opcode: number;
|
||||||
|
payloadData: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireSummary {
|
||||||
|
totalFrames: number;
|
||||||
|
totalBytes: number;
|
||||||
|
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
||||||
|
byPhase: Record<
|
||||||
|
WirePhase,
|
||||||
|
{
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
byType: Array<{ type: string; frames: number; bytes: number }>;
|
||||||
|
rpcCounts: Array<{ requestType: string; count: number }>;
|
||||||
|
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
fetchPages: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
type: string;
|
||||||
|
requestId: string | null;
|
||||||
|
entries: number | null;
|
||||||
|
hasMore: boolean | null;
|
||||||
|
bytes: number;
|
||||||
|
}>;
|
||||||
|
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
||||||
|
providerSnapshots: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
type: string;
|
||||||
|
requestId: string | null;
|
||||||
|
totalModels: number;
|
||||||
|
totalModes: number;
|
||||||
|
bytes: number;
|
||||||
|
providers: ProviderSnapshotWireEntry[];
|
||||||
|
}>;
|
||||||
|
providerSnapshotTotals: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
provider: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
maxModels: number;
|
||||||
|
maxModes: number;
|
||||||
|
statuses: string[];
|
||||||
|
}>;
|
||||||
|
fork: {
|
||||||
|
sourceHome: string | null;
|
||||||
|
targetHome: string | null;
|
||||||
|
copiedFiles: number | null;
|
||||||
|
copiedBytes: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWorkspaceTestIds(elements: Element[]): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const element of elements.slice(0, 3)) {
|
||||||
|
const value = element.getAttribute("data-testid");
|
||||||
|
if (value) {
|
||||||
|
result.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WireMonitor {
|
||||||
|
private phase: WirePhase = "startup";
|
||||||
|
private session: CDPSession | null = null;
|
||||||
|
readonly records: WireFrameRecord[] = [];
|
||||||
|
|
||||||
|
async start(page: Page): Promise<void> {
|
||||||
|
this.session = await page.context().newCDPSession(page);
|
||||||
|
await this.session.send("Network.enable");
|
||||||
|
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
||||||
|
this.record("sent", event);
|
||||||
|
});
|
||||||
|
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
||||||
|
this.record("received", event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase(phase: WirePhase): void {
|
||||||
|
this.phase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCompletedStartupFetches(): boolean {
|
||||||
|
return (
|
||||||
|
this.records.some(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
record.type === "fetch_agents_response" &&
|
||||||
|
record.hasMore === false,
|
||||||
|
) &&
|
||||||
|
this.records.some(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
record.type === "fetch_workspaces_response" &&
|
||||||
|
record.hasMore === false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
||||||
|
return {
|
||||||
|
totalFrames: this.records.length,
|
||||||
|
totalBytes: sumBytes(this.records),
|
||||||
|
byDirection: {
|
||||||
|
sent: summarizeDirection(this.records, "sent"),
|
||||||
|
received: summarizeDirection(this.records, "received"),
|
||||||
|
},
|
||||||
|
byPhase: {
|
||||||
|
startup: summarizePhase(this.records, "startup"),
|
||||||
|
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
||||||
|
},
|
||||||
|
fetchPages: this.records
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
(record.type === "fetch_agents_response" ||
|
||||||
|
record.type === "fetch_workspaces_response"),
|
||||||
|
)
|
||||||
|
.map((record) => ({
|
||||||
|
phase: record.phase,
|
||||||
|
type: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId,
|
||||||
|
entries: record.entryCount,
|
||||||
|
hasMore: record.hasMore,
|
||||||
|
bytes: record.bytes,
|
||||||
|
})),
|
||||||
|
clickedWorkspaces,
|
||||||
|
providerSnapshots: this.records
|
||||||
|
.filter((record) => record.direction === "received" && record.providerEntries)
|
||||||
|
.map((record) => ({
|
||||||
|
phase: record.phase,
|
||||||
|
type: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId,
|
||||||
|
totalModels: sumProviderModels(record.providerEntries ?? []),
|
||||||
|
totalModes: sumProviderModes(record.providerEntries ?? []),
|
||||||
|
bytes: record.bytes,
|
||||||
|
providers: record.providerEntries ?? [],
|
||||||
|
})),
|
||||||
|
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
||||||
|
fork: {
|
||||||
|
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
||||||
|
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
||||||
|
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
||||||
|
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
||||||
|
if (event.response.opcode !== 1) {
|
||||||
|
this.records.push({
|
||||||
|
phase: this.phase,
|
||||||
|
direction,
|
||||||
|
bytes: Buffer.byteLength(event.response.payloadData),
|
||||||
|
type: `opcode:${event.response.opcode}`,
|
||||||
|
requestId: null,
|
||||||
|
entryCount: null,
|
||||||
|
hasMore: null,
|
||||||
|
providerEntries: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.records.push({
|
||||||
|
phase: this.phase,
|
||||||
|
direction,
|
||||||
|
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
||||||
|
...parseWireMessage(event.response.payloadData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: string | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadData) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return emptyParsedWireMessage();
|
||||||
|
}
|
||||||
|
const envelope = parsed as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
const unwrapped =
|
||||||
|
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||||
|
? envelope.message
|
||||||
|
: parsed;
|
||||||
|
const message = unwrapped as {
|
||||||
|
type?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
|
payload?: {
|
||||||
|
requestId?: unknown;
|
||||||
|
entries?: unknown;
|
||||||
|
agents?: unknown;
|
||||||
|
pageInfo?: { hasMore?: unknown };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const entries = readMessageEntries(message.payload);
|
||||||
|
const hasMore = readMessageHasMore(message.payload);
|
||||||
|
const messageType = typeof message.type === "string" ? message.type : null;
|
||||||
|
const providerEntries = isProviderSnapshotMessage(messageType)
|
||||||
|
? parseProviderSnapshotEntries(message.payload)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
type: messageType,
|
||||||
|
requestId: readMessageRequestId(message),
|
||||||
|
entryCount: entries ? entries.length : null,
|
||||||
|
hasMore,
|
||||||
|
providerEntries,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return emptyParsedWireMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageRequestId(message: {
|
||||||
|
requestId?: unknown;
|
||||||
|
payload?: { requestId?: unknown };
|
||||||
|
}): string | null {
|
||||||
|
if (typeof message.requestId === "string") {
|
||||||
|
return message.requestId;
|
||||||
|
}
|
||||||
|
if (typeof message.payload?.requestId === "string") {
|
||||||
|
return message.payload.requestId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
||||||
|
return (
|
||||||
|
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageEntries(
|
||||||
|
payload:
|
||||||
|
| {
|
||||||
|
entries?: unknown;
|
||||||
|
agents?: unknown;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
): unknown[] | null {
|
||||||
|
if (Array.isArray(payload?.entries)) {
|
||||||
|
return payload.entries;
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.agents)) {
|
||||||
|
return payload.agents;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageHasMore(
|
||||||
|
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyParsedWireMessage(): ParsedWireMessage {
|
||||||
|
return {
|
||||||
|
type: null,
|
||||||
|
requestId: null,
|
||||||
|
entryCount: null,
|
||||||
|
hasMore: null,
|
||||||
|
providerEntries: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entries = (payload as { entries?: unknown }).entries;
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const provider = (entry as { provider?: unknown }).provider;
|
||||||
|
if (typeof provider !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const models = (entry as { models?: unknown }).models;
|
||||||
|
const modes = (entry as { modes?: unknown }).modes;
|
||||||
|
const status = (entry as { status?: unknown }).status;
|
||||||
|
providerEntries.push({
|
||||||
|
provider,
|
||||||
|
status: typeof status === "string" ? status : null,
|
||||||
|
modelCount: Array.isArray(models) ? models.length : 0,
|
||||||
|
modeCount: Array.isArray(modes) ? modes.length : 0,
|
||||||
|
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerEntries.length > 0 ? providerEntries : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumBytes(records: WireFrameRecord[]): number {
|
||||||
|
return records.reduce((sum, record) => sum + record.bytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
||||||
|
const selected = records.filter((record) => record.direction === direction);
|
||||||
|
return {
|
||||||
|
frames: selected.length,
|
||||||
|
bytes: sumBytes(selected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
||||||
|
const selected = records.filter((record) => record.phase === phase);
|
||||||
|
return {
|
||||||
|
frames: selected.length,
|
||||||
|
bytes: sumBytes(selected),
|
||||||
|
byType: summarizeByType(selected),
|
||||||
|
rpcCounts: summarizeRpcCounts(selected),
|
||||||
|
rpcs: summarizeRpcs(selected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeByType(records: WireFrameRecord[]): Array<{
|
||||||
|
type: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
}> {
|
||||||
|
const byType = new Map<string, { frames: number; bytes: number }>();
|
||||||
|
for (const record of records) {
|
||||||
|
const type = record.type ?? "unknown";
|
||||||
|
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
||||||
|
current.frames += 1;
|
||||||
|
current.bytes += record.bytes;
|
||||||
|
byType.set(type, current);
|
||||||
|
}
|
||||||
|
return [...byType.entries()]
|
||||||
|
.map(([type, value]) => Object.assign({ type }, value))
|
||||||
|
.sort((left, right) => right.bytes - left.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
||||||
|
const responsesByRequestId = new Map<string, string>();
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "received" || !record.requestId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
||||||
|
.map((record) => ({
|
||||||
|
requestType: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId ?? "",
|
||||||
|
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRpcCounts(
|
||||||
|
records: WireFrameRecord[],
|
||||||
|
): Array<{ requestType: string; count: number }> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([requestType, count]) => ({ requestType, count }))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
||||||
|
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
||||||
|
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProviderSnapshots(
|
||||||
|
records: WireFrameRecord[],
|
||||||
|
): WireSummary["providerSnapshotTotals"] {
|
||||||
|
const byProvider = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
phase: WirePhase;
|
||||||
|
provider: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
maxModels: number;
|
||||||
|
maxModes: number;
|
||||||
|
statuses: Set<string>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "received" || !record.providerEntries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of record.providerEntries) {
|
||||||
|
const key = `${record.phase}:${entry.provider}`;
|
||||||
|
const current = byProvider.get(key) ?? {
|
||||||
|
phase: record.phase,
|
||||||
|
provider: entry.provider,
|
||||||
|
frames: 0,
|
||||||
|
bytes: 0,
|
||||||
|
maxModels: 0,
|
||||||
|
maxModes: 0,
|
||||||
|
statuses: new Set<string>(),
|
||||||
|
};
|
||||||
|
current.frames += 1;
|
||||||
|
current.bytes += entry.bytes;
|
||||||
|
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
||||||
|
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
||||||
|
if (entry.status) {
|
||||||
|
current.statuses.add(entry.status);
|
||||||
|
}
|
||||||
|
byProvider.set(key, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byProvider.values()]
|
||||||
|
.map((entry) => ({
|
||||||
|
phase: entry.phase,
|
||||||
|
provider: entry.provider,
|
||||||
|
frames: entry.frames,
|
||||||
|
bytes: entry.bytes,
|
||||||
|
maxModels: entry.maxModels,
|
||||||
|
maxModes: entry.maxModes,
|
||||||
|
statuses: [...entry.statuses].sort(),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.bytes - left.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
||||||
|
await testInfo.attach("startup-wire-metrics.json", {
|
||||||
|
body: JSON.stringify(summary, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("ad hoc startup wire metrics", () => {
|
||||||
|
test.skip(
|
||||||
|
process.env.E2E_WIRE_METRICS !== "1",
|
||||||
|
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const monitor = new WireMonitor();
|
||||||
|
await monitor.start(page);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page, 120_000);
|
||||||
|
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
|
||||||
|
const workspaceTestIds = await page
|
||||||
|
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
||||||
|
.evaluateAll(extractWorkspaceTestIds);
|
||||||
|
|
||||||
|
monitor.setPhase("workspace_clicks");
|
||||||
|
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
||||||
|
for (const testId of workspaceTestIds) {
|
||||||
|
const row = page.getByTestId(testId);
|
||||||
|
if (!(await row.isVisible().catch(() => false))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const beforeFrames = monitor.records.length;
|
||||||
|
const beforeBytes = sumBytes(monitor.records);
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
clickedWorkspaces.push({
|
||||||
|
testId,
|
||||||
|
frames: monitor.records.length - beforeFrames,
|
||||||
|
bytes: sumBytes(monitor.records) - beforeBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = monitor.summarize(clickedWorkspaces);
|
||||||
|
await attachSummary(testInfo, summary);
|
||||||
|
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
||||||
|
|
||||||
|
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
||||||
|
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||||
|
import {
|
||||||
|
installTerminalRenderProbe,
|
||||||
|
readTerminalRenderProbe,
|
||||||
|
resetTerminalRenderProbe,
|
||||||
|
startTerminalFrameSampling,
|
||||||
|
summarizeTerminalRenderProbe,
|
||||||
|
} from "./helpers/terminal-probes";
|
||||||
|
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
|
||||||
|
let lastBufferText = "";
|
||||||
|
let lastProbe = await readTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
lastBufferText = await getTerminalBufferText(page);
|
||||||
|
lastProbe = await readTerminalRenderProbe(page);
|
||||||
|
return (
|
||||||
|
lastProbe.altEnterWrites > 0 &&
|
||||||
|
lastProbe.altExitWrites > 0 &&
|
||||||
|
lastBufferText.includes(afterAlt)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intervals: [50],
|
||||||
|
message: `wait for alternate-screen exit and ${afterAlt} output`,
|
||||||
|
timeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
|
||||||
|
{
|
||||||
|
afterAlt,
|
||||||
|
probe: summarizeTerminalRenderProbe(lastProbe),
|
||||||
|
bufferTextTail: lastBufferText.slice(-500),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Terminal alternate-screen transitions", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await installTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
|
||||||
|
await harness.setupPrompt(page);
|
||||||
|
|
||||||
|
const terminal = harness.terminalSurface(page);
|
||||||
|
const historyReady = `HISTORY_READY_${Date.now()}`;
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
function hasHistoryReady(text: string): boolean {
|
||||||
|
return text.includes(historyReady);
|
||||||
|
}
|
||||||
|
await waitForTerminalContent(page, hasHistoryReady, 10_000);
|
||||||
|
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const settledProbe = await readTerminalRenderProbe(page);
|
||||||
|
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
const afterAlt = `AFTER_ALT_${Date.now()}`;
|
||||||
|
await startTerminalFrameSampling(page);
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
|
||||||
|
const probeSummary = summarizeTerminalRenderProbe(probe);
|
||||||
|
|
||||||
|
await testInfo.attach("alternate-screen-probe", {
|
||||||
|
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
|
||||||
|
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
|
||||||
|
expect(
|
||||||
|
probe.altEnterWrites,
|
||||||
|
"test command should enter the alternate screen",
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
|
||||||
|
|
||||||
|
const finalBufferText = await getTerminalBufferText(page);
|
||||||
|
expect(finalBufferText).toContain(historyReady);
|
||||||
|
expect(finalBufferText).toContain(afterAlt);
|
||||||
|
|
||||||
|
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
|
||||||
|
return (
|
||||||
|
frame.text.includes("$") &&
|
||||||
|
!frame.text.includes(historyReady) &&
|
||||||
|
!frame.text.includes(afterAlt) &&
|
||||||
|
frame.nonEmptyRows <= 2 &&
|
||||||
|
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const suspiciousFrames = probe.frames.filter(isSuspiciousFrame);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
suspiciousFrames,
|
||||||
|
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import type { Page, TestInfo } from "@playwright/test";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
|
||||||
|
import {
|
||||||
|
installTerminalKeystrokeStressProbe,
|
||||||
|
readTerminalKeystrokeStressReport,
|
||||||
|
resetTerminalKeystrokeStressProbe,
|
||||||
|
type LatencyStats,
|
||||||
|
} from "./helpers/terminal-probes";
|
||||||
|
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const INPUT_TEXT = buildStressText(600);
|
||||||
|
const BIG_DIFF_BYTES = 256_000;
|
||||||
|
const SMALL_AGENT_STREAM_UPDATES = 1000;
|
||||||
|
const STRESS_TIMEOUT_MS = 15_000;
|
||||||
|
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||||
|
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
|
interface DaemonEchoReport {
|
||||||
|
inputTextLength: number;
|
||||||
|
inputFrameCount: number;
|
||||||
|
outputEventCount: number;
|
||||||
|
echoedBytes: number;
|
||||||
|
sendToOutputMs: LatencyStats;
|
||||||
|
firstSendAt: number;
|
||||||
|
lastOutputAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalPerfDescribe("Terminal keystroke stress", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs daemon-only and app keystroke echo latency under burst input", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(75_000);
|
||||||
|
|
||||||
|
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
|
||||||
|
try {
|
||||||
|
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
|
||||||
|
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
|
||||||
|
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
|
||||||
|
|
||||||
|
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
} finally {
|
||||||
|
await harness.killTerminal(daemonTerminal.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await installTerminalKeystrokeStressProbe(page);
|
||||||
|
const appBaselineReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress",
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
|
||||||
|
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
|
||||||
|
|
||||||
|
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const appSmallChunksReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress-small-agent-chunks",
|
||||||
|
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
|
||||||
|
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
|
||||||
|
|
||||||
|
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
|
||||||
|
SMALL_AGENT_STREAM_UPDATES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const appBigDiffReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress-big-diff",
|
||||||
|
bigDiffBytes: BIG_DIFF_BYTES,
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
|
||||||
|
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
|
||||||
|
|
||||||
|
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
|
||||||
|
BIG_DIFF_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function measureAppBurstEcho(input: {
|
||||||
|
page: Page;
|
||||||
|
harness: TerminalE2EHarness;
|
||||||
|
terminalName: string;
|
||||||
|
bigDiffBytes?: number;
|
||||||
|
agentStreamUpdateCount?: number;
|
||||||
|
}) {
|
||||||
|
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
|
||||||
|
try {
|
||||||
|
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
|
||||||
|
await input.harness.setupPrompt(input.page);
|
||||||
|
|
||||||
|
const agent =
|
||||||
|
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
|
||||||
|
? null
|
||||||
|
: await input.harness.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: input.harness.tempRepo.path,
|
||||||
|
title: "Large WebSocket payload",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bigDiffBytes = input.bigDiffBytes;
|
||||||
|
const agentStreamUpdateCount = input.agentStreamUpdateCount;
|
||||||
|
const startAgentLoad =
|
||||||
|
agent === null
|
||||||
|
? null
|
||||||
|
: () => {
|
||||||
|
if (bigDiffBytes !== undefined) {
|
||||||
|
return emitLargeDiffAgentPayload(input.harness, {
|
||||||
|
agentId: agent.id,
|
||||||
|
bytes: bigDiffBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agentStreamUpdateCount !== undefined) {
|
||||||
|
return emitRapidAgentStreamUpdates(input.harness, {
|
||||||
|
agentId: agent.id,
|
||||||
|
count: agentStreamUpdateCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminal = input.harness.terminalSurface(input.page);
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await input.page.waitForTimeout(200);
|
||||||
|
await resetTerminalKeystrokeStressProbe(input.page);
|
||||||
|
|
||||||
|
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
|
||||||
|
await waitForAppStressEcho(input.page, INPUT_TEXT);
|
||||||
|
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
|
||||||
|
if (input.bigDiffBytes !== undefined) {
|
||||||
|
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
|
||||||
|
}
|
||||||
|
if (input.agentStreamUpdateCount !== undefined) {
|
||||||
|
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
|
||||||
|
}
|
||||||
|
await activeAgentLoadPromise;
|
||||||
|
|
||||||
|
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
|
||||||
|
} finally {
|
||||||
|
await input.harness.killTerminal(appTerminal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitRapidAgentStreamUpdates(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { agentId: string; count: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitLargeDiffAgentPayload(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { agentId: string; bytes: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await harness.client.sendAgentMessage(
|
||||||
|
input.agentId,
|
||||||
|
`emit ${input.bytes} byte large diff agent stream update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureDaemonBurstEcho(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
terminal: TerminalInstance,
|
||||||
|
inputText: string,
|
||||||
|
): Promise<DaemonEchoReport> {
|
||||||
|
await harness.client.subscribeTerminal(terminal.id);
|
||||||
|
|
||||||
|
const outputTimesByByte: number[] = [];
|
||||||
|
let outputEventCount = 0;
|
||||||
|
let echoedBytes = 0;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
|
||||||
|
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputEventCount += 1;
|
||||||
|
const text = decoder.decode(event.data);
|
||||||
|
const now = performance.now();
|
||||||
|
const previousEchoedBytes = echoedBytes;
|
||||||
|
echoedBytes += text.length;
|
||||||
|
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
|
||||||
|
outputTimesByByte[index] = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
echoedBytes = 0;
|
||||||
|
outputTimesByByte.length = 0;
|
||||||
|
|
||||||
|
const sendTimes: number[] = [];
|
||||||
|
for (const char of inputText) {
|
||||||
|
sendTimes.push(performance.now());
|
||||||
|
harness.client.sendTerminalInput(terminal.id, {
|
||||||
|
type: "input",
|
||||||
|
data: char,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForDaemonEchoBytes({
|
||||||
|
getEchoedBytes: () => echoedBytes,
|
||||||
|
expectedBytes: inputText.length,
|
||||||
|
timeoutMs: STRESS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||||
|
return {
|
||||||
|
inputTextLength: inputText.length,
|
||||||
|
inputFrameCount: sendTimes.length,
|
||||||
|
outputEventCount,
|
||||||
|
echoedBytes,
|
||||||
|
sendToOutputMs: summarizeLatency(latencies),
|
||||||
|
firstSendAt: sendTimes[0] ?? 0,
|
||||||
|
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDaemonEchoBytes(input: {
|
||||||
|
getEchoedBytes: () => number;
|
||||||
|
expectedBytes: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const deadline = Date.now() + input.timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (input.getEchoedBytes() >= input.expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
|
||||||
|
const tail = text.slice(-80);
|
||||||
|
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.outputFramePayloadBytes >= expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.agentStreamTextMessageCount >= expectedCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
|
||||||
|
await testInfo.attach(name, {
|
||||||
|
body: JSON.stringify(value, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStressText(length: number): string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
let output = "";
|
||||||
|
while (output.length < length) {
|
||||||
|
output += alphabet;
|
||||||
|
}
|
||||||
|
return output.slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLatency(values: number[]): LatencyStats {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const percentile = (p: number) => {
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
|
||||||
|
};
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
minMs: round2(sorted[0] ?? 0),
|
||||||
|
p50Ms: round2(percentile(50)),
|
||||||
|
p95Ms: round2(percentile(95)),
|
||||||
|
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||||
|
avgMs: round2(total / values.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
|
|||||||
const THROUGHPUT_BUDGET_MS = 30_000;
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
const KEYSTROKE_SAMPLE_COUNT = 20;
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
const KEYSTROKE_P95_BUDGET_MS = 150;
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||||
|
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
test.describe("Terminal wire performance", () => {
|
terminalPerfDescribe("Terminal wire performance", () => {
|
||||||
let client: TerminalPerfDaemonClient;
|
let client: TerminalPerfDaemonClient;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
let workspaceId: string;
|
let workspaceId: string;
|
||||||
|
|||||||
@@ -139,6 +139,35 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.evaluate(
|
||||||
|
({ agentId, serverId: targetServerId }) => {
|
||||||
|
globalThis.dispatchEvent(
|
||||||
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
|
detail: {
|
||||||
|
data: {
|
||||||
|
serverId: targetServerId,
|
||||||
|
agentId,
|
||||||
|
reason: "finished",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ agentId: secondAgent.id, serverId },
|
||||||
|
);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||||
|
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||||
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceScriptStarter {
|
||||||
|
startWorkspaceScript(
|
||||||
|
workspaceId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<{
|
||||||
|
workspaceId: string;
|
||||||
|
scriptName: string;
|
||||||
|
terminalId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||||
async function navigateToWorkspaceViaSidebar(
|
async function navigateToWorkspaceViaSidebar(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
@@ -226,7 +238,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches script terminals after setup completes", async ({ page }) => {
|
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||||
@@ -263,12 +275,15 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
// Wait for the script terminal tab to appear in the tabs bar.
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
// The tab title shows the command, not the script name.
|
await page.getByTestId("workspace-scripts-button").click();
|
||||||
|
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("workspace-scripts-start-web").click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Click the script terminal tab
|
|
||||||
await terminalTab.click();
|
await terminalTab.click();
|
||||||
|
|
||||||
// Verify the terminal surface rendered
|
// Verify the terminal surface rendered
|
||||||
@@ -294,7 +309,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches workspace scripts after setup completes", async () => {
|
test("launches workspace scripts through an explicit daemon request", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -303,7 +318,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
},
|
},
|
||||||
scripts: {
|
scripts: {
|
||||||
editor: {
|
editor: {
|
||||||
command: "npm run dev",
|
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -325,13 +340,24 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
throw new Error(result.error ?? "Failed to create workspace");
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
}
|
}
|
||||||
const workspaceDir = result.workspace.workspaceDirectory;
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
|
const workspaceId = String(result.workspace.id);
|
||||||
|
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
|
const scriptClient = client as typeof client & WorkspaceScriptStarter;
|
||||||
|
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
|
||||||
|
expect(startResult).toMatchObject({
|
||||||
|
workspaceId,
|
||||||
|
scriptName: "editor",
|
||||||
|
terminalId: expect.any(String),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const terminals = await client.listTerminals(workspaceDir);
|
const terminals = await client.listTerminals(workspaceDir);
|
||||||
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
|
return terminals.terminals.find(findEditorTerminal) ?? null;
|
||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
"ascAppId": "6758887924"
|
"ascAppId": "6758887924"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"releaseStatus": "draft"
|
"track": "production",
|
||||||
|
"releaseStatus": "completed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
app_identifier("sh.paseo")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = "sh.paseo"
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Submit the latest TestFlight build to App Store Review"
|
||||||
|
lane :submit_review do
|
||||||
|
api_key = app_store_connect_api_key(
|
||||||
|
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||||
|
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||||
|
key_filepath: ENV.fetch("ASC_KEY_P8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
build_number = latest_testflight_build_number(
|
||||||
|
api_key: api_key,
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
)
|
||||||
|
|
||||||
|
wait_for_build_processing_to_be_complete(
|
||||||
|
api_key: api_key,
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
build_number: build_number.to_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
deliver(
|
||||||
|
api_key: api_key,
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
build_number: build_number.to_s,
|
||||||
|
submit_for_review: true,
|
||||||
|
automatic_release: true,
|
||||||
|
force: true,
|
||||||
|
skip_binary_upload: true,
|
||||||
|
skip_screenshots: true,
|
||||||
|
skip_metadata: true,
|
||||||
|
precheck_include_in_app_purchases: false,
|
||||||
|
submission_information: {
|
||||||
|
add_id_info_uses_idfa: false,
|
||||||
|
export_compliance_uses_encryption: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Maestro Flows
|
||||||
|
|
||||||
|
This directory contains local mobile UI flows. Keep flows small enough that a
|
||||||
|
failure screenshot proves the intended behavior, not just that the app launched.
|
||||||
|
|
||||||
|
## New Workspace Android Flow
|
||||||
|
|
||||||
|
Use these files when debugging or extending workspace creation on Android:
|
||||||
|
|
||||||
|
- `test-workspace-create-android-crash.sh` runs the full regression harness.
|
||||||
|
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
|
||||||
|
harness.
|
||||||
|
- `record-workspace-create-android-focus.sh` records only the focused repro
|
||||||
|
window after setup.
|
||||||
|
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
|
||||||
|
sidebar open and a prepared project visible.
|
||||||
|
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
|
||||||
|
and performs the actual workspace creation.
|
||||||
|
|
||||||
|
The reusable pieces live in `flows/`:
|
||||||
|
|
||||||
|
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
|
||||||
|
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
|
||||||
|
the welcome screen is visible.
|
||||||
|
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
|
||||||
|
the compact Android sidebar, and waits for the prepared project.
|
||||||
|
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
|
||||||
|
new-workspace action and waits for `/new`.
|
||||||
|
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
|
||||||
|
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
|
||||||
|
the app landed on the created workspace.
|
||||||
|
|
||||||
|
Compose new workspace scenarios out of these primitives instead of copying the
|
||||||
|
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
|
||||||
|
file into the same temp directory, so nested `runFlow: flows/...` paths keep
|
||||||
|
working with `${PASEO_MAESTRO_*}` placeholders.
|
||||||
|
|
||||||
|
The flow is intentionally strict. It must:
|
||||||
|
|
||||||
|
1. Open a prepared project from the daemon.
|
||||||
|
2. Tap the project row's new-workspace action.
|
||||||
|
3. Select an actual provider/model before tapping `Create`.
|
||||||
|
4. Tap `Create`.
|
||||||
|
5. Assert the app lands on a workspace header and the draft composer.
|
||||||
|
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
|
||||||
|
visible.
|
||||||
|
7. For the shell harness, grep logcat for `failed to insert view` and
|
||||||
|
`specified child already has a parent`.
|
||||||
|
|
||||||
|
Do not weaken this flow to only wait for `message-input-root`. That can pass on
|
||||||
|
the wrong route. The header assertion and the `New workspace` negative assertion
|
||||||
|
are what prove the redirect actually completed.
|
||||||
|
|
||||||
|
The scripts assume a development build with package id `sh.paseo.debug`, an
|
||||||
|
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
|
||||||
|
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
|
||||||
|
daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Handle Expo dev launcher/dev menu screens when testing Android development
|
||||||
|
# builds. Release builds no-op through these optional/conditional steps.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "DEVELOPMENT SERVERS"
|
||||||
|
commands:
|
||||||
|
# Expo dev launcher text matching is flaky on Android here; tap the first
|
||||||
|
# development server row by coordinate as the fallback.
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,8%"
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Continue"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Performance monitor"
|
||||||
|
commands:
|
||||||
|
# Close the Expo dev menu if it opens as a bottom sheet.
|
||||||
|
- tapOn:
|
||||||
|
point: "89%,9%"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Connect to the local daemon only when the app is on the welcome screen.
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "welcome-screen"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: "welcome-direct-connection"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "add-host-modal"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "direct-host-input"
|
||||||
|
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-submit"
|
||||||
@@ -24,4 +24,3 @@ appId: sh.paseo
|
|||||||
commands:
|
commands:
|
||||||
- tapOn:
|
- tapOn:
|
||||||
point: "50%,20%"
|
point: "50%,20%"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
# visible. Ends on the New Workspace screen.
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
|
||||||
|
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
|
||||||
|
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New workspace"
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Select a real provider/model on the New Workspace screen. Do not skip this
|
||||||
|
# in redirect tests: without a selected model, Create only shows validation and
|
||||||
|
# never exercises workspace creation.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "agent-preferences-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "agent-preferences-sheet"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "combined-model-selector"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Select model"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "Codex"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "GPT-5.4"
|
||||||
|
|
||||||
|
- tapOn: "Close"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start on the New Workspace screen after model selection. Taps Create and
|
||||||
|
# proves the redirect completed into a workspace draft-agent tab.
|
||||||
|
|
||||||
|
- tapOn: "Create"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "workspace-header-title"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- assertNotVisible: "New workspace"
|
||||||
|
- assertNotVisible: "Select a model"
|
||||||
|
- assertNotVisible: "There was a problem loading the project."
|
||||||
|
- assertNotVisible: "The specified child already has a parent"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from the connected home screen and leave the Android sidebar open with
|
||||||
|
# the prepared project visible.
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "menu-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
timeout: 30000
|
||||||
@@ -3,4 +3,3 @@ appId: sh.paseo
|
|||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
- runFlow: flows/dev-client.yaml
|
- runFlow: flows/dev-client.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records only the Android workspace-creation repro window.
|
||||||
|
#
|
||||||
|
# The setup Maestro flow gets the app to the open sidebar with a prepared
|
||||||
|
# project visible. Recording starts after that, then the focused flow taps the
|
||||||
|
# new-workspace button, selects a provider/model, taps Create, and asserts the
|
||||||
|
# app lands on the created workspace rather than remaining on /new.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
|
||||||
|
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
||||||
|
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
||||||
|
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
|
||||||
|
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
|
||||||
|
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
|
||||||
|
echo "=== Focused Android Workspace Create Recording ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Video: $LOCAL_VIDEO"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-focus-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Staging app at open sidebar..."
|
||||||
|
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Recording focused create flow..."
|
||||||
|
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
|
||||||
|
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
|
||||||
|
SCREENRECORD_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
|
||||||
|
FOCUS_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
|
||||||
|
ls -lh "$LOCAL_VIDEO"
|
||||||
|
|
||||||
|
if [ "$FOCUS_STATUS" -ne 0 ]; then
|
||||||
|
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$FOCUS_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Focused recording complete."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Android Maestro harness for the workspace-creation redirect crash.
|
||||||
|
#
|
||||||
|
# Starts from a clean app state, connects the Android app to the local daemon,
|
||||||
|
# opens a prepared git project, creates a workspace through the UI, and captures
|
||||||
|
# adb logcat around the redirect window.
|
||||||
|
#
|
||||||
|
# This harness is deliberately stronger than "composer is visible": it selects
|
||||||
|
# a model, taps Create, asserts the workspace header, asserts the New Workspace
|
||||||
|
# route is gone, and fails if logcat contains the Android Fabric view-parent
|
||||||
|
# crash signature.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
||||||
|
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$FLOW_TEMPLATE" "$FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$SERVER_EXPORTS" ]; then
|
||||||
|
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
||||||
|
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
else
|
||||||
|
PROJECT_PARENT=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
echo "=== Workspace Create Android Crash Harness ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "App id: $PASEO_MAESTRO_APP_ID"
|
||||||
|
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
|
||||||
|
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
echo "Rendered flow: $FLOW"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Preparing Android port reverse..."
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectId: payload.workspace.projectId,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
LOGCAT_PID=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$LOGCAT_PID" ]; then
|
||||||
|
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Capturing Android logcat..."
|
||||||
|
adb logcat -c || true
|
||||||
|
adb logcat -v time > "$OUT_DIR/logcat.txt" &
|
||||||
|
LOGCAT_PID="$!"
|
||||||
|
|
||||||
|
echo "Running Maestro flow..."
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
|
||||||
|
MAESTRO_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cleanup
|
||||||
|
LOGCAT_PID=""
|
||||||
|
|
||||||
|
if [ "$MAESTRO_STATUS" -ne 0 ]; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$MAESTRO_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
|
||||||
|
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Android regression/reproduction flow for creating a workspace from an existing project.
|
||||||
|
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- takeScreenshot: 01-connected
|
||||||
|
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 02-new-workspace
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 03-created-workspace
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
|
||||||
|
# already left the sidebar open with the prepared project visible.
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Setup-only flow. It stops with the Android sidebar open and the prepared
|
||||||
|
# project visible, so focused recordings can start at the actual repro window.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
@@ -22,7 +22,7 @@ const escapedAppSrcRoot = appSrcRoot
|
|||||||
const pathSeparatorPattern = "[\\\\/]";
|
const pathSeparatorPattern = "[\\\\/]";
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...(config.resolver.extraNodeModules ?? {}),
|
...config.resolver.extraNodeModules,
|
||||||
react: path.join(appNodeModulesRoot, "react"),
|
react: path.join(appNodeModulesRoot, "react"),
|
||||||
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
|
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
|
||||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||||
|
|||||||
+14
-18
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"version": "0.1.63-beta.1",
|
||||||
"version": "0.1.60-beta.1",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "APP_VARIANT=development expo start",
|
"start": "APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
@@ -17,8 +17,9 @@
|
|||||||
"ios:release": "expo run:ios --configuration Release",
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
@@ -29,18 +30,14 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
"@getpaseo/highlight": "0.1.63-beta.1",
|
||||||
"@getpaseo/server": "0.1.60-beta.1",
|
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
|
||||||
"@react-navigation/elements": "^2.6.3",
|
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
@@ -53,11 +50,10 @@
|
|||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"base64-js": "^1.5.1",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"expo": "^54.0.18",
|
"expo": "^54.0.18",
|
||||||
|
"expo-asset": "~12.0.12",
|
||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-av": "^16.0.7",
|
|
||||||
"expo-build-properties": "^1.0.9",
|
"expo-build-properties": "^1.0.9",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.7",
|
"expo-clipboard": "~8.0.7",
|
||||||
@@ -65,7 +61,6 @@
|
|||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "^6.0.15",
|
"expo-dev-client": "^6.0.15",
|
||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-font": "~14.0.9",
|
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-image-picker": "^17.0.8",
|
"expo-image-picker": "^17.0.8",
|
||||||
@@ -75,34 +70,30 @@
|
|||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
"expo-sharing": "^14.0.8",
|
"expo-sharing": "^14.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-symbols": "~1.0.7",
|
|
||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
|
||||||
"react-native-draggable-flatlist": "^4.0.3",
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-controller": "^1.19.2",
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "0.33.8",
|
"react-native-nitro-modules": "0.33.8",
|
||||||
"react-native-permissions": "^5.4.2",
|
|
||||||
"react-native-popover-view": "^6.1.0",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-securerandom": "^1.0.1",
|
|
||||||
"react-native-svg": "^15.14.0",
|
"react-native-svg": "^15.14.0",
|
||||||
"react-native-unistyles": "^3.0.15",
|
"react-native-unistyles": "^3.0.15",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
"use-sync-external-store": "^1.6.0",
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -110,11 +101,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitest/browser": "^3.2.4",
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
"material-icon-theme": "^5.32.0",
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const baseURL =
|
|
||||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./e2e",
|
|
||||||
globalSetup: "./e2e/global-setup.ts",
|
|
||||||
timeout: 60_000,
|
|
||||||
expect: {
|
|
||||||
timeout: 10_000,
|
|
||||||
},
|
|
||||||
fullyParallel: false,
|
|
||||||
workers: 1,
|
|
||||||
retries: process.env.CI ? 1 : 0,
|
|
||||||
reporter: [["list"]],
|
|
||||||
use: {
|
|
||||||
baseURL,
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
video: "retain-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "Desktop Firefox",
|
|
||||||
use: { ...devices["Desktop Firefox"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const baseURL =
|
|
||||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./e2e",
|
|
||||||
globalSetup: "./e2e/global-setup.ts",
|
|
||||||
timeout: 60_000,
|
|
||||||
expect: {
|
|
||||||
timeout: 10_000,
|
|
||||||
},
|
|
||||||
fullyParallel: false,
|
|
||||||
workers: 1,
|
|
||||||
retries: 0,
|
|
||||||
reporter: [["list"]],
|
|
||||||
use: {
|
|
||||||
baseURL,
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
video: "retain-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "Desktop Safari",
|
|
||||||
use: { ...devices["Desktop Safari"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="%LANG_ISO_CODE%">
|
<html lang="%LANG_ISO_CODE%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -21,8 +21,9 @@
|
|||||||
}
|
}
|
||||||
/* Prevent white flash before React mounts */
|
/* Prevent white flash before React mounts */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html, body {
|
html,
|
||||||
background-color: #181B1A;
|
body {
|
||||||
|
background-color: #181b1a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* These styles make the root element full-height */
|
/* These styles make the root element full-height */
|
||||||
@@ -38,17 +39,17 @@
|
|||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select,
|
select,
|
||||||
[role='button'],
|
[role="button"],
|
||||||
[role='link'],
|
[role="link"],
|
||||||
[role='textbox'],
|
[role="textbox"],
|
||||||
[role='combobox'],
|
[role="combobox"],
|
||||||
[role='tab'],
|
[role="tab"],
|
||||||
[role='switch'],
|
[role="switch"],
|
||||||
[role='checkbox'],
|
[role="checkbox"],
|
||||||
[role='slider'],
|
[role="slider"],
|
||||||
[role='menuitem'],
|
[role="menuitem"],
|
||||||
[tabindex],
|
[tabindex],
|
||||||
[contenteditable='true'] {
|
[contenteditable="true"] {
|
||||||
-webkit-app-region: no-drag !important;
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid #20744A;
|
outline: 2px solid #20744a;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+179
-155
@@ -1,5 +1,8 @@
|
|||||||
import "@/styles/unistyles";
|
import "@/styles/unistyles";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
useGlobalSearchParams,
|
useGlobalSearchParams,
|
||||||
@@ -7,113 +10,98 @@ import {
|
|||||||
usePathname,
|
usePathname,
|
||||||
useRouter,
|
useRouter,
|
||||||
} from "expo-router";
|
} from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
|
||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
|
||||||
import { View, Text } from "react-native";
|
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getHostRuntimeStore,
|
|
||||||
useHosts,
|
|
||||||
useHostMutations,
|
|
||||||
useHostRuntimeClient,
|
|
||||||
} from "@/runtime/host-runtime";
|
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as Linking from "expo-linking";
|
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import * as Notifications from "expo-notifications";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { CommandCenter } from "@/components/command-center";
|
||||||
|
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import {
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
SidebarAnimationProvider,
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
useSidebarAnimation,
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
|
||||||
import { resolveActiveHost } from "@/utils/active-host";
|
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
|
||||||
import { queryClient } from "@/query/query-client";
|
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
SidebarAnimationProvider,
|
||||||
type WebNotificationClickDetail,
|
useSidebarAnimation,
|
||||||
ensureOsNotificationPermission,
|
} from "@/contexts/sidebar-animation-context";
|
||||||
} from "@/utils/os-notifications";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||||
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
|
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
getHostRuntimeStore,
|
||||||
mapPathnameToServer,
|
useHostMutations,
|
||||||
parseServerIdFromPathname,
|
useHostRuntimeClient,
|
||||||
parseHostAgentRouteFromPathname,
|
useHosts,
|
||||||
parseWorkspaceOpenIntent,
|
} from "@/runtime/host-runtime";
|
||||||
decodeWorkspaceIdFromPathSegment,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import {
|
import {
|
||||||
addBrowserActiveWorkspaceLocationListener,
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
syncNavigationActiveWorkspace,
|
syncNavigationActiveWorkspace,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
|
import {
|
||||||
|
buildHostRootRoute,
|
||||||
|
mapPathnameToServer,
|
||||||
|
parseHostAgentRouteFromPathname,
|
||||||
|
parseServerIdFromPathname,
|
||||||
|
parseWorkspaceOpenIntent,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||||
|
import {
|
||||||
|
ensureOsNotificationPermission,
|
||||||
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
|
type WebNotificationClickDetail,
|
||||||
|
} from "@/utils/os-notifications";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
export type HostRuntimeBootstrapState = {
|
export interface HostRuntimeBootstrapState {
|
||||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
|
||||||
|
|
||||||
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const firstValue = value[0];
|
|
||||||
if (typeof firstValue !== "string") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const trimmed = firstValue.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
@@ -124,7 +112,36 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
|
const target = resolveNotificationTarget(data);
|
||||||
|
const serverId = target.serverId;
|
||||||
|
const agentId = target.agentId;
|
||||||
|
if (serverId && agentId) {
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
const agent = session?.agents.get(agentId);
|
||||||
|
const workspaceId =
|
||||||
|
target.workspaceId ??
|
||||||
|
resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: session?.workspaces.values(),
|
||||||
|
workspaceDirectory: agent?.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
pin: true,
|
||||||
|
});
|
||||||
|
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(buildNotificationRoute(data));
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -145,7 +162,7 @@ function PushNotificationRouter() {
|
|||||||
(payload as { data?: unknown }).data !== null
|
(payload as { data?: unknown }).data !== null
|
||||||
? (payload as { data: Record<string, unknown> }).data
|
? (payload as { data: Record<string, unknown> }).data
|
||||||
: undefined;
|
: undefined;
|
||||||
router.push(buildNotificationRoute(data) as any);
|
openNotification(data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -158,6 +175,7 @@ function PushNotificationRouter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDesktopNotificationListener = unlisten;
|
removeDesktopNotificationListener = unlisten;
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +183,7 @@ function PushNotificationRouter() {
|
|||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
openNotification(customEvent.detail?.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
@@ -198,7 +216,7 @@ function PushNotificationRouter() {
|
|||||||
const data = response.notification.request.content.data as
|
const data = response.notification.request.content.data as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
router.push(buildNotificationRoute(data) as any);
|
openNotification(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||||
@@ -207,12 +225,13 @@ function PushNotificationRouter() {
|
|||||||
if (response) {
|
if (response) {
|
||||||
openFromResponse(response);
|
openFromResponse(response);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [router]);
|
}, [openNotification]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -319,7 +338,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
setPhase("connecting");
|
||||||
|
setError(null);
|
||||||
|
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPhase("online");
|
setPhase("online");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -401,8 +422,6 @@ function AppContainer({
|
|||||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
@@ -439,35 +458,6 @@ function AppContainer({
|
|||||||
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||||
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const bp = UnistylesRuntime.breakpoint;
|
|
||||||
const screenW = UnistylesRuntime.screen.width;
|
|
||||||
const screenH = UnistylesRuntime.screen.height;
|
|
||||||
const isElectron = getIsElectronRuntime();
|
|
||||||
const windowW = isWeb ? window.innerWidth : undefined;
|
|
||||||
const windowH = isWeb ? window.innerHeight : undefined;
|
|
||||||
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
|
||||||
const ua = isWeb ? navigator.userAgent : undefined;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[layout-debug]",
|
|
||||||
JSON.stringify({
|
|
||||||
breakpoint: bp,
|
|
||||||
isCompactLayout,
|
|
||||||
isElectron,
|
|
||||||
chromeEnabled,
|
|
||||||
isFocusModeEnabled,
|
|
||||||
agentListOpen,
|
|
||||||
sidebarWidth,
|
|
||||||
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
|
||||||
unistylesScreen: { w: screenW, h: screenH },
|
|
||||||
window: { w: windowW, h: windowH },
|
|
||||||
devicePixelRatio: dpr,
|
|
||||||
userAgent: ua,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: keyboardShortcutsEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
@@ -494,7 +484,8 @@ function AppContainer({
|
|||||||
</View>
|
</View>
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<UpdateBanner />
|
<UpdateCalloutSource />
|
||||||
|
<DaemonVersionMismatchCalloutSource />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
<WorkspaceShortcutTargetsSubscriber
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
@@ -673,9 +664,10 @@ function OfferLinkListener({
|
|||||||
void upsertDaemonFromOfferUrl(url)
|
void upsertDaemonFromOfferUrl(url)
|
||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId));
|
||||||
|
return;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -747,6 +739,7 @@ function OpenProjectListener() {
|
|||||||
if (!disposed && pending) {
|
if (!disposed && pending) {
|
||||||
maybeOpenProject(pending);
|
maybeOpenProject(pending);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
@@ -764,6 +757,7 @@ function OpenProjectListener() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unlisten = dispose;
|
unlisten = dispose;
|
||||||
|
return;
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
@@ -783,8 +777,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
|
const storeReady = useStoreReady();
|
||||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const shouldShowAppChrome = activeServerId !== null;
|
const shouldShowAppChrome =
|
||||||
|
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeServerId || hosts.length === 0) {
|
if (!activeServerId || hosts.length === 0) {
|
||||||
@@ -827,19 +823,24 @@ function FaviconStatusSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||||
|
|
||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const stackScreenOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
headerShown: false,
|
||||||
|
animation: "none" as const,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[theme.colors.surface0],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack screenOptions={stackScreenOptions}>
|
||||||
screenOptions={{
|
<Stack.Screen name="index" />
|
||||||
headerShown: false,
|
|
||||||
animation: "none",
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings/index" />
|
<Stack.Screen name="settings/index" />
|
||||||
@@ -854,12 +855,11 @@ function RootStack() {
|
|||||||
outside this route-level native-stack API.
|
outside this route-level native-stack API.
|
||||||
*/}
|
*/}
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -887,35 +887,59 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShell() {
|
||||||
|
return (
|
||||||
|
<SidebarAnimationProvider>
|
||||||
|
<HorizontalScrollProvider>
|
||||||
|
<OpenProjectListener />
|
||||||
|
<AppWithSidebar>
|
||||||
|
<RootStack />
|
||||||
|
</AppWithSidebar>
|
||||||
|
</HorizontalScrollProvider>
|
||||||
|
</SidebarAnimationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<HostRuntimeBootstrapProvider>
|
||||||
|
<PushNotificationRouter />
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||||
|
</ToastProvider>
|
||||||
|
</SidebarCalloutProvider>
|
||||||
|
</HostRuntimeBootstrapProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<PortalProvider>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<KeyboardProvider>
|
||||||
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</PortalProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const gestureRootStyle = useMemo(
|
||||||
|
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
|
||||||
|
[theme.colors.surface0],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
<GestureHandlerRootView style={gestureRootStyle}>
|
||||||
<NavigationActiveWorkspaceObserver />
|
<NavigationActiveWorkspaceObserver />
|
||||||
<PortalProvider>
|
<RootProviders>
|
||||||
<SafeAreaProvider>
|
<RuntimeProviders>
|
||||||
<KeyboardProvider>
|
<AppShell />
|
||||||
<QueryProvider>
|
</RuntimeProviders>
|
||||||
<HostRuntimeBootstrapProvider>
|
</RootProviders>
|
||||||
<PushNotificationRouter />
|
|
||||||
<ToastProvider>
|
|
||||||
<ProvidersWrapper>
|
|
||||||
<SidebarAnimationProvider>
|
|
||||||
<HorizontalScrollProvider>
|
|
||||||
<OpenProjectListener />
|
|
||||||
<AppWithSidebar>
|
|
||||||
<RootStack />
|
|
||||||
</AppWithSidebar>
|
|
||||||
</HorizontalScrollProvider>
|
|
||||||
</SidebarAnimationProvider>
|
|
||||||
</ProvidersWrapper>
|
|
||||||
</ToastProvider>
|
|
||||||
</HostRuntimeBootstrapProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
</KeyboardProvider>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</PortalProvider>
|
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||||
@@ -44,7 +44,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace("/" as any);
|
router.replace("/" as Href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: resolvedWorkspaceId,
|
workspaceId: resolvedWorkspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as Href,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||||
@@ -107,11 +107,12 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as Href,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId));
|
||||||
|
return;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -64,14 +65,16 @@ function stripOpenSearchParamFromBrowserUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearConsumedOpenIntent(input: {
|
function clearConsumedOpenIntent(input: {
|
||||||
navigation: { setParams: (...args: any[]) => void };
|
navigation: { setParams: (params: { open?: string | undefined }) => void };
|
||||||
router: { replace: (...args: any[]) => void };
|
router: ReturnType<typeof useRouter>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
|
||||||
input.navigation.setParams({ open: undefined });
|
input.navigation.setParams({ open: undefined });
|
||||||
stripOpenSearchParamFromBrowserUrl();
|
if (isWeb) {
|
||||||
|
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||||
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
@@ -86,6 +89,7 @@ function HostWorkspaceLayoutContent() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
|
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
@@ -126,11 +130,16 @@ function HostWorkspaceLayoutContent() {
|
|||||||
if (!rootNavigationState?.key) {
|
if (!rootNavigationState?.key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!hasHydratedWorkspaceLayoutStore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
clearConsumedOpenIntent({
|
clearConsumedOpenIntent({
|
||||||
navigation,
|
navigation: navigation as unknown as {
|
||||||
|
setParams: (params: { open?: string | undefined }) => void;
|
||||||
|
},
|
||||||
router,
|
router,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -154,16 +163,26 @@ function HostWorkspaceLayoutContent() {
|
|||||||
// skips search params). Strip ?open from the browser URL directly so the
|
// skips search params). Strip ?open from the browser URL directly so the
|
||||||
// address bar reflects the clean workspace route.
|
// address bar reflects the clean workspace route.
|
||||||
clearConsumedOpenIntent({
|
clearConsumedOpenIntent({
|
||||||
navigation,
|
navigation: navigation as unknown as {
|
||||||
|
setParams: (params: { open?: string | undefined }) => void;
|
||||||
|
},
|
||||||
router,
|
router,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIntentConsumed(true);
|
setIntentConsumed(true);
|
||||||
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
|
}, [
|
||||||
|
hasHydratedWorkspaceLayoutStore,
|
||||||
|
navigation,
|
||||||
|
openValue,
|
||||||
|
rootNavigationState?.key,
|
||||||
|
router,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
if (openValue && !intentConsumed) {
|
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||||
import { useHostMutations } from "@/runtime/host-runtime";
|
import { useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
@@ -147,7 +147,7 @@ export default function PairScanScreen() {
|
|||||||
try {
|
try {
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
router.replace("/" as any);
|
router.replace("/" as Href);
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -198,15 +198,29 @@ export default function PairScanScreen() {
|
|||||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||||
|
const handleRequestPermission = useCallback(() => {
|
||||||
|
void requestPermission();
|
||||||
|
}, [requestPermission]);
|
||||||
|
|
||||||
|
const bodyStyle = useMemo(
|
||||||
|
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
||||||
|
[insets.bottom, theme.spacing],
|
||||||
|
);
|
||||||
|
const helperTextStyle = useMemo(
|
||||||
|
() => [styles.helperText, { color: theme.colors.foreground }],
|
||||||
|
[theme.colors.foreground],
|
||||||
|
);
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader title="Scan QR" onBack={() => router.back()} />
|
<BackHeader title="Scan QR" onBack={handleRouterBack} />
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={bodyStyle}>
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||||
<Text style={styles.permissionBody}>
|
<Text style={styles.permissionBody}>
|
||||||
QR scanning isn't supported in the web build. Use "Paste link" instead.
|
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||||
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||||
@@ -223,14 +237,14 @@ export default function PairScanScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader title="Scan QR" onBack={closeToSource} />
|
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||||
|
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={bodyStyle}>
|
||||||
{!granted ? (
|
{!granted ? (
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>Camera permission</Text>
|
<Text style={styles.permissionTitle}>Camera permission</Text>
|
||||||
<Text style={styles.permissionBody}>
|
<Text style={styles.permissionBody}>
|
||||||
Allow camera access to scan the pairing QR code from your daemon.
|
Allow camera access to scan the pairing QR code from your daemon.
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -239,21 +253,17 @@ export default function PairScanScreen() {
|
|||||||
<CameraView
|
<CameraView
|
||||||
style={styles.camera}
|
style={styles.camera}
|
||||||
facing="back"
|
facing="back"
|
||||||
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
|
||||||
onBarcodeScanned={handleScan}
|
onBarcodeScanned={handleScan}
|
||||||
/>
|
/>
|
||||||
<View style={styles.overlay} pointerEvents="none">
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
<View style={styles.scanFrame}>
|
<View style={styles.scanFrame}>
|
||||||
<View style={[styles.corner, styles.cornerTL]} />
|
<View style={CORNER_TL_STYLE} />
|
||||||
<View style={[styles.corner, styles.cornerTR]} />
|
<View style={CORNER_TR_STYLE} />
|
||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={CORNER_BL_STYLE} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={CORNER_BR_STYLE} />
|
||||||
</View>
|
</View>
|
||||||
{isPairing ? (
|
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
|
||||||
Pairing…
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -261,3 +271,9 @@ export default function PairScanScreen() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] };
|
||||||
|
const CORNER_TL_STYLE = [styles.corner, styles.cornerTL];
|
||||||
|
const CORNER_TR_STYLE = [styles.corner, styles.cornerTR];
|
||||||
|
const CORNER_BL_STYLE = [styles.corner, styles.cornerBL];
|
||||||
|
const CORNER_BR_STYLE = [styles.corner, styles.cornerBR];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ export default function SettingsSectionRoute() {
|
|||||||
const params = useLocalSearchParams<{ section?: string }>();
|
const params = useLocalSearchParams<{ section?: string }>();
|
||||||
const rawSection = typeof params.section === "string" ? params.section : "";
|
const rawSection = typeof params.section === "string" ? params.section : "";
|
||||||
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
||||||
|
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||||
|
|
||||||
return <SettingsScreen view={{ kind: "section", section }} />;
|
return <SettingsScreen view={view} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
|
||||||
export default function SettingsHostRoute() {
|
export default function SettingsHostRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
|
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
<SettingsScreen view={{ kind: "host", serverId }} />
|
<SettingsScreen view={view} />
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { buildSettingsSectionRoute } from "@/utils/host-routes";
|
import { buildSettingsSectionRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
const ROOT_VIEW = { kind: "root" as const };
|
||||||
|
|
||||||
export default function SettingsIndexRoute() {
|
export default function SettingsIndexRoute() {
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
|
||||||
@@ -10,5 +12,5 @@ export default function SettingsIndexRoute() {
|
|||||||
return <Redirect href={buildSettingsSectionRoute("general")} />;
|
return <Redirect href={buildSettingsSectionRoute("general")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsScreen view={{ kind: "root" }} />;
|
return <SettingsScreen view={ROOT_VIEW} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
||||||
|
|
||||||
|
const fileSystemMock = vi.hoisted(() => ({
|
||||||
|
getInfoAsync: vi.fn(async (uri: string) => {
|
||||||
|
if (uri.endsWith("/preview-assets/")) {
|
||||||
|
return { exists: true, isDirectory: true };
|
||||||
|
}
|
||||||
|
return { exists: true, isDirectory: false, size: 4 };
|
||||||
|
}),
|
||||||
|
makeDirectoryAsync: vi.fn(async () => {}),
|
||||||
|
writeAsStringAsync: vi.fn(async () => {}),
|
||||||
|
copyAsync: vi.fn(async () => {}),
|
||||||
|
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||||
|
deleteAsync: vi.fn(async () => {}),
|
||||||
|
readDirectoryAsync: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-file-system/legacy", () => ({
|
||||||
|
cacheDirectory: "file:///cache/",
|
||||||
|
EncodingType: { Base64: "base64" },
|
||||||
|
getInfoAsync: fileSystemMock.getInfoAsync,
|
||||||
|
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
||||||
|
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
||||||
|
copyAsync: fileSystemMock.copyAsync,
|
||||||
|
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
||||||
|
deleteAsync: fileSystemMock.deleteAsync,
|
||||||
|
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("local file attachment store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fileSystemMock.getInfoAsync.mockClear();
|
||||||
|
fileSystemMock.makeDirectoryAsync.mockClear();
|
||||||
|
fileSystemMock.writeAsStringAsync.mockClear();
|
||||||
|
fileSystemMock.copyAsync.mockClear();
|
||||||
|
fileSystemMock.readAsStringAsync.mockClear();
|
||||||
|
fileSystemMock.deleteAsync.mockClear();
|
||||||
|
fileSystemMock.readDirectoryAsync.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes raw base64 sources directly to the managed file path", async () => {
|
||||||
|
const store = createLocalFileAttachmentStore({
|
||||||
|
storageType: "native-file",
|
||||||
|
baseDirectoryName: "preview-assets",
|
||||||
|
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = await store.save({
|
||||||
|
id: "preview_8_test",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "result.png",
|
||||||
|
source: { kind: "base64", base64: "AAECAw==" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
||||||
|
"file:///cache/preview-assets/preview_8_test.png",
|
||||||
|
"AAECAw==",
|
||||||
|
{ encoding: "base64" },
|
||||||
|
);
|
||||||
|
expect(attachment).toMatchObject({
|
||||||
|
id: "preview_8_test",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "native-file",
|
||||||
|
storageKey: "/cache/preview-assets/preview_8_test.png",
|
||||||
|
fileName: "result.png",
|
||||||
|
byteSize: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.source.kind === "base64") {
|
||||||
|
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const base64 = await blobToBase64(input.source.blob);
|
const base64 = await blobToBase64(input.source.blob);
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
@@ -107,12 +114,14 @@ export function createLocalFileAttachmentStore(params: {
|
|||||||
await ensureDirectory(baseDirectory);
|
await ensureDirectory(baseDirectory);
|
||||||
|
|
||||||
const id = input.id ?? generateAttachmentId();
|
const id = input.id ?? generateAttachmentId();
|
||||||
const mimeTypeFromSource =
|
let mimeTypeFromSource: string | undefined;
|
||||||
input.source.kind === "data_url"
|
if (input.source.kind === "data_url") {
|
||||||
? parseDataUrl(input.source.dataUrl).mimeType
|
mimeTypeFromSource = parseDataUrl(input.source.dataUrl).mimeType;
|
||||||
: input.source.kind === "blob"
|
} else if (input.source.kind === "blob") {
|
||||||
? input.source.blob.type
|
mimeTypeFromSource = input.source.blob.type;
|
||||||
: undefined;
|
} else {
|
||||||
|
mimeTypeFromSource = undefined;
|
||||||
|
}
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
|
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
|
||||||
const fileName = input.fileName ?? null;
|
const fileName = input.fileName ?? null;
|
||||||
const extension = extensionForAttachment({ fileName, mimeType });
|
const extension = extensionForAttachment({ fileName, mimeType });
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||||
|
import { __setAttachmentStoreForTests } from "./store";
|
||||||
|
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
||||||
|
|
||||||
|
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||||
|
return {
|
||||||
|
id: input.id ?? "att_1",
|
||||||
|
mimeType: input.mimeType ?? "image/png",
|
||||||
|
storageType: input.storageType ?? "web-indexeddb",
|
||||||
|
storageKey: input.storageKey ?? "att_1",
|
||||||
|
fileName: input.fileName,
|
||||||
|
byteSize: input.byteSize,
|
||||||
|
createdAt: input.createdAt ?? 1700000000000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecordingStore(): AttachmentStore & {
|
||||||
|
savedSources: SaveAttachmentInput[];
|
||||||
|
releasedUrls: string[];
|
||||||
|
} {
|
||||||
|
const savedSources: SaveAttachmentInput[] = [];
|
||||||
|
const releasedUrls: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
savedSources,
|
||||||
|
releasedUrls,
|
||||||
|
async save(input) {
|
||||||
|
savedSources.push(input);
|
||||||
|
return createAttachment({
|
||||||
|
id: input.id,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
fileName: input.fileName,
|
||||||
|
byteSize: 4,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async encodeBase64({ attachment }) {
|
||||||
|
return `${attachment.id}:base64`;
|
||||||
|
},
|
||||||
|
async resolvePreviewUrl({ attachment }) {
|
||||||
|
return `blob:${attachment.id}`;
|
||||||
|
},
|
||||||
|
async releasePreviewUrl({ url }) {
|
||||||
|
releasedUrls.push(url);
|
||||||
|
},
|
||||||
|
async delete() {},
|
||||||
|
async garbageCollect() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("attachment service", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
__setAttachmentStoreForTests(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
||||||
|
const store = createRecordingStore();
|
||||||
|
__setAttachmentStoreForTests(store);
|
||||||
|
|
||||||
|
const attachment = await persistAttachmentFromBase64({
|
||||||
|
id: "att_base64",
|
||||||
|
base64: "AAECAw==",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "image.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(attachment).toEqual({
|
||||||
|
id: "att_base64",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "att_1",
|
||||||
|
fileName: "image.png",
|
||||||
|
byteSize: 4,
|
||||||
|
createdAt: 1700000000000,
|
||||||
|
});
|
||||||
|
expect(store.savedSources).toEqual([
|
||||||
|
{
|
||||||
|
id: "att_base64",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "image.png",
|
||||||
|
source: { kind: "base64", base64: "AAECAw==" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps provider send output byte-compatible", async () => {
|
||||||
|
const store = createRecordingStore();
|
||||||
|
__setAttachmentStoreForTests(store);
|
||||||
|
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
|
||||||
|
|
||||||
|
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
|
||||||
|
{ data: "att_send:base64", mimeType: "image/jpeg" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function persistAttachmentFromBase64(input: {
|
||||||
|
base64: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
id?: string;
|
||||||
|
}): Promise<AttachmentMetadata> {
|
||||||
|
const store = await getAttachmentStore();
|
||||||
|
return await store.save({
|
||||||
|
id: input.id,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
fileName: input.fileName,
|
||||||
|
source: { kind: "base64", base64: input.base64 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistAttachmentFromFileUri(input: {
|
export async function persistAttachmentFromFileUri(input: {
|
||||||
uri: string;
|
uri: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
|||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
if (isElectronRuntime()) {
|
if (isElectronRuntime()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } =
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
await import("../desktop/attachments/desktop-attachment-store");
|
||||||
);
|
|
||||||
return createDesktopAttachmentStore();
|
return createDesktopAttachmentStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type ComposerAttachment =
|
|||||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||||
|
|
||||||
export type AttachmentDataSource =
|
export type AttachmentDataSource =
|
||||||
|
| { kind: "base64"; base64: string }
|
||||||
| { kind: "blob"; blob: Blob }
|
| { kind: "blob"; blob: Blob }
|
||||||
| { kind: "data_url"; dataUrl: string }
|
| { kind: "data_url"; dataUrl: string }
|
||||||
| { kind: "file_uri"; uri: string };
|
| { kind: "file_uri"; uri: string };
|
||||||
|
|||||||
@@ -7,29 +7,37 @@ export function useAttachmentPreviewUrl(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||||
|
const attachmentRef = useRef(attachment);
|
||||||
|
attachmentRef.current = attachment;
|
||||||
|
|
||||||
|
const id = attachment?.id;
|
||||||
|
const storageType = attachment?.storageType;
|
||||||
|
const storageKey = attachment?.storageKey;
|
||||||
|
const mimeType = attachment?.mimeType;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let currentUrl: string | null = null;
|
let currentUrl: string | null = null;
|
||||||
|
const current = attachmentRef.current;
|
||||||
|
|
||||||
activeAttachmentRef.current = attachment ?? null;
|
activeAttachmentRef.current = current ?? null;
|
||||||
if (!attachment) {
|
if (!current) {
|
||||||
setUrl(null);
|
setUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveAttachmentPreviewUrl(attachment);
|
const resolved = await resolveAttachmentPreviewUrl(current);
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
|
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUrl = resolved;
|
currentUrl = resolved;
|
||||||
setUrl(resolved);
|
setUrl(resolved);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[attachments] Failed to resolve preview URL", {
|
console.error("[attachments] Failed to resolve preview URL", {
|
||||||
attachmentId: attachment.id,
|
attachmentId: current.id,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
@@ -49,7 +57,7 @@ export function useAttachmentPreviewUrl(
|
|||||||
url: currentUrl,
|
url: currentUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
|
}, [id, storageType, storageKey, mimeType]);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user