mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
153 Commits
dotfile-cl
...
v0.1.107
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9553f4b328 | ||
|
|
fc7c753382 | ||
|
|
96e68fea76 | ||
|
|
1a4d7852a8 | ||
|
|
c2a1ac7c3b | ||
|
|
4a1534cacd | ||
|
|
a849bc6bda | ||
|
|
a788a0f843 | ||
|
|
a1581e66b0 | ||
|
|
2658132384 | ||
|
|
ec93ca866e | ||
|
|
66445adc07 | ||
|
|
41d882859c | ||
|
|
88397655f7 | ||
|
|
18de06c2a4 | ||
|
|
a9ba0392b7 | ||
|
|
cf6c014b6b | ||
|
|
e18cfb7639 | ||
|
|
c05e337cde | ||
|
|
71c66823aa | ||
|
|
a1821d5863 | ||
|
|
5db070a4d9 | ||
|
|
461be47c91 | ||
|
|
92d7066601 | ||
|
|
d28e174b38 | ||
|
|
a257a1c5f6 | ||
|
|
144f951a79 | ||
|
|
51fea4b7e0 | ||
|
|
e98ba1beb1 | ||
|
|
3bbbfbc880 | ||
|
|
f86226a196 | ||
|
|
120842264e | ||
|
|
a017ddfc29 | ||
|
|
03dcda7c41 | ||
|
|
ada0955334 | ||
|
|
ca5c786ccf | ||
|
|
5ae53c7e55 | ||
|
|
c800c6a3f1 | ||
|
|
adc9b3b43c | ||
|
|
5d18edd31e | ||
|
|
6c764f211a | ||
|
|
28a27b02d3 | ||
|
|
860fcb2e35 | ||
|
|
4c72bf0209 | ||
|
|
8c639fd796 | ||
|
|
aa656772ef | ||
|
|
4dd783c36a | ||
|
|
e6a316456d | ||
|
|
3c2339f841 | ||
|
|
61a9117a6b | ||
|
|
1f0285c06b | ||
|
|
a689ccb0af | ||
|
|
cac71ee8f5 | ||
|
|
a743a22496 | ||
|
|
b2c6476817 | ||
|
|
6af1379357 | ||
|
|
15d3091104 | ||
|
|
ebaecace2b | ||
|
|
d96d599466 | ||
|
|
29a3261dd6 | ||
|
|
c7406a415d | ||
|
|
202c417347 | ||
|
|
3259a69466 | ||
|
|
cab0e4b3f8 | ||
|
|
6ca1f4dac5 | ||
|
|
6f3ab98986 | ||
|
|
251d88a37a | ||
|
|
c72bcb49e5 | ||
|
|
1a03171f0c | ||
|
|
f3fdeab131 | ||
|
|
fdc6a4a57d | ||
|
|
ed67e6e0f1 | ||
|
|
5ec7ef9771 | ||
|
|
4fb70668f2 | ||
|
|
06c3161008 | ||
|
|
a85139c3f3 | ||
|
|
be756c9004 | ||
|
|
8d0b3e69e8 | ||
|
|
d9b65e269f | ||
|
|
d3e1fe48f6 | ||
|
|
783f43c580 | ||
|
|
503a5a9e1e | ||
|
|
f21c997b17 | ||
|
|
7c1da0077b | ||
|
|
d318629121 | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da | ||
|
|
b3eb77981a | ||
|
|
3e28b9b59e | ||
|
|
42d1a2e4e3 | ||
|
|
e98d8c7a36 | ||
|
|
9c220c2451 | ||
|
|
b28f291493 | ||
|
|
f226222638 | ||
|
|
6ce4e3f507 | ||
|
|
8d3a43e05d | ||
|
|
7cb0ccba60 | ||
|
|
f1a8e9a563 | ||
|
|
75b11ac93e | ||
|
|
8680bc2236 | ||
|
|
e67e25628b | ||
|
|
860fe92fdd | ||
|
|
ab62a023fd | ||
|
|
cc21262130 | ||
|
|
2a226b3872 | ||
|
|
a13fa4e614 | ||
|
|
ccd2bb2bf3 | ||
|
|
570e652496 | ||
|
|
6d55565d19 | ||
|
|
fec723c775 | ||
|
|
1bf50349a5 | ||
|
|
cda1ba43cc | ||
|
|
f22865df62 | ||
|
|
33ffeb4d42 | ||
|
|
6781521c8e | ||
|
|
4ca89f21f9 | ||
|
|
9430a897e8 | ||
|
|
b0088303fd | ||
|
|
2ba3ad53ab | ||
|
|
b2714ccd89 | ||
|
|
82993c2197 | ||
|
|
742d47fe71 | ||
|
|
441b935b73 | ||
|
|
b2ad903005 | ||
|
|
554e525186 | ||
|
|
71ce96b60f | ||
|
|
d9615c1e98 | ||
|
|
6fea8305a7 | ||
|
|
172de4fbc2 | ||
|
|
adc0c01782 | ||
|
|
3eed8c31a2 | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 | ||
|
|
2263469342 | ||
|
|
5a0ea3385e | ||
|
|
3708eddbcd | ||
|
|
f41dde8c72 | ||
|
|
d3e8f77914 | ||
|
|
48d07dedb5 | ||
|
|
31e9a210d0 | ||
|
|
bf5e3b47e7 | ||
|
|
b8b66816ca | ||
|
|
2b46caa20e | ||
|
|
20385bdb50 |
@@ -1,11 +1,31 @@
|
||||
.git
|
||||
.debug.conversations
|
||||
.debug
|
||||
.dev
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
.paseo
|
||||
**/.paseo-provider-history
|
||||
.plans
|
||||
.tasks
|
||||
.valknut
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
PLAN.md
|
||||
valknut-report.html
|
||||
valknut-report.json
|
||||
.env*
|
||||
**/.env*
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
*.pem
|
||||
**/*.pem
|
||||
**/.secrets
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -76,7 +76,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -113,7 +112,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
@@ -170,7 +168,6 @@ jobs:
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -202,7 +199,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -227,7 +223,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -244,8 +239,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
include:
|
||||
- { label: "shard 1/4", shard: 1, desktop: false }
|
||||
- { label: "shard 2/4", shard: 2, desktop: false }
|
||||
- { label: "shard 3/4", shard: 3, desktop: false }
|
||||
- { label: "shard 4/4", shard: 4, desktop: false }
|
||||
- { label: "desktop overlay", shard: "desktop", desktop: true }
|
||||
name: playwright (${{ matrix.label }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -270,7 +270,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -282,13 +281,19 @@ jobs:
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -27,7 +27,6 @@ jobs:
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
7
.github/workflows/deploy-relay.yml
vendored
7
.github/workflows/deploy-relay.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
62
.github/workflows/docker.yml
vendored
62
.github/workflows/docker.yml
vendored
@@ -5,7 +5,22 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -13,7 +28,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Paseo version to build. Required when publish is true."
|
||||
description: "Expected source version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
@@ -24,15 +39,6 @@ on:
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
@@ -57,7 +63,6 @@ jobs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
@@ -68,7 +73,6 @@ jobs:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -76,9 +80,9 @@ jobs:
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
@@ -87,7 +91,6 @@ jobs:
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -104,24 +107,6 @@ jobs:
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -137,14 +122,13 @@ jobs:
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
@@ -160,8 +144,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
@@ -193,8 +177,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,6 +84,7 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
192
.oxlintrc.json
192
.oxlintrc.json
@@ -84,6 +84,198 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@tanstack/react-query",
|
||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||
},
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/data/**/*.{ts,tsx}",
|
||||
"packages/app/src/**/*.test.{ts,tsx}",
|
||||
"packages/app/src/**/*.spec.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/assistant-file-links/use-file-link.ts",
|
||||
"packages/app/src/components/message.tsx",
|
||||
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
||||
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
||||
"packages/app/src/desktop/hooks/use-install-status.ts",
|
||||
"packages/app/src/desktop/settings/desktop-settings.ts",
|
||||
"packages/app/src/git/pull-request-panel/use-data.ts",
|
||||
"packages/app/src/git/use-github-search-query.ts",
|
||||
"packages/app/src/git/use-pr-status-query.ts",
|
||||
"packages/app/src/git/use-status-query.ts",
|
||||
"packages/app/src/hooks/use-archive-agent.ts",
|
||||
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
||||
"packages/app/src/hooks/use-agent-commands-query.ts",
|
||||
"packages/app/src/hooks/use-agent-history.ts",
|
||||
"packages/app/src/hooks/use-branch-switcher.ts",
|
||||
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
||||
"packages/app/src/hooks/use-draft-agent-features.ts",
|
||||
"packages/app/src/hooks/use-form-preferences.ts",
|
||||
"packages/app/src/hooks/use-is-local-daemon.ts",
|
||||
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
||||
"packages/app/src/hooks/use-preferred-editor.ts",
|
||||
"packages/app/src/hooks/use-project-icon-query.ts",
|
||||
"packages/app/src/hooks/use-settings/index.ts",
|
||||
"packages/app/src/panels/terminal-panel.tsx",
|
||||
"packages/app/src/projects/project-icons.ts",
|
||||
"packages/app/src/provider-usage/use-provider-usage.ts",
|
||||
"packages/app/src/screens/project-settings-screen.tsx",
|
||||
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
||||
"packages/app/src/workspace/desktop-open-targets.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-native-unistyles",
|
||||
"importNames": ["useUnistyles"],
|
||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/app/_layout.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/agent-status-dot.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/browser-pane.electron.tsx",
|
||||
"packages/app/src/components/browser-pane.tsx",
|
||||
"packages/app/src/components/browser-pane.web.tsx",
|
||||
"packages/app/src/components/command-center.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/explorer-sidebar.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/pair-link-modal.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
"packages/app/src/components/quitting-overlay.tsx",
|
||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||
"packages/app/src/components/resize-handle.tsx",
|
||||
"packages/app/src/components/rewind/rewind-menu.tsx",
|
||||
"packages/app/src/components/settings-textarea.tsx",
|
||||
"packages/app/src/components/sidebar-callout.tsx",
|
||||
"packages/app/src/components/split-container.tsx",
|
||||
"packages/app/src/components/split-drop-zone.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/ui/alert.tsx",
|
||||
"packages/app/src/components/ui/autocomplete.tsx",
|
||||
"packages/app/src/components/ui/combobox.tsx",
|
||||
"packages/app/src/components/ui/context-menu.tsx",
|
||||
"packages/app/src/components/ui/dropdown-menu.tsx",
|
||||
"packages/app/src/components/ui/external-link.tsx",
|
||||
"packages/app/src/components/volume-meter.tsx",
|
||||
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/composer/agent-controls/index.tsx",
|
||||
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
||||
"packages/app/src/constants/layout.ts",
|
||||
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
||||
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
||||
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
||||
"packages/app/src/desktop/components/integrations-section.tsx",
|
||||
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
||||
"packages/app/src/git/actions-split-button.tsx",
|
||||
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
||||
"packages/app/src/hosts/host-chooser.tsx",
|
||||
"packages/app/src/screens/open-project-screen.tsx",
|
||||
"packages/app/src/screens/projects-screen.tsx",
|
||||
"packages/app/src/screens/sessions-screen.tsx",
|
||||
"packages/app/src/screens/settings-screen.tsx",
|
||||
"packages/app/src/screens/settings/host-page.tsx",
|
||||
"packages/app/src/screens/settings/providers-section.tsx",
|
||||
"packages/app/src/screens/settings/settings-group.tsx",
|
||||
"packages/app/src/screens/startup-splash-screen.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@tanstack/react-query",
|
||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/file-explorer-pane.tsx",
|
||||
"packages/app/src/components/file-pane.tsx",
|
||||
"packages/app/src/components/import-session-sheet.tsx",
|
||||
"packages/app/src/components/project-picker-modal.tsx",
|
||||
"packages/app/src/desktop/components/pair-device-section.tsx",
|
||||
"packages/app/src/screens/new-workspace-screen.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/e2e/fixtures.ts"],
|
||||
"rules": {
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.107 - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- Inspect provider-created subagents and their live conversations from the Subagents track ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet))
|
||||
- Fork chats with every supported agent provider ([#2022](https://github.com/getpaseo/paseo/pull/2022))
|
||||
|
||||
### Improved
|
||||
|
||||
- Add projects directly from New Workspace when none are configured ([#2026](https://github.com/getpaseo/paseo/pull/2026))
|
||||
- New terminals open at the correct size immediately ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter))
|
||||
- Sidebar footer actions now explain themselves with tooltips ([#2025](https://github.com/getpaseo/paseo/pull/2025))
|
||||
- Codex shell tool calls show only the command being run ([#2029](https://github.com/getpaseo/paseo/pull/2029))
|
||||
- Custom ACP providers keep file and terminal work in the agent environment by default ([#2024](https://github.com/getpaseo/paseo/pull/2024))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Large tables no longer make iOS chats unresponsive
|
||||
- Chat controls remain clickable near the scroll-to-bottom button ([#2007](https://github.com/getpaseo/paseo/pull/2007))
|
||||
- Oversized tool output no longer slows or floods chat timelines ([#2020](https://github.com/getpaseo/paseo/pull/2020))
|
||||
- Cross-provider subagents can use providers without mode settings ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi's internal metadata tasks no longer clutter normal session history ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi chats remain usable after canceling extension commands ([#2019](https://github.com/getpaseo/paseo/pull/2019))
|
||||
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
||||
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
### Improved
|
||||
|
||||
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
||||
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
||||
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
||||
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
||||
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
||||
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
||||
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
||||
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
||||
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
||||
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
||||
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
||||
|
||||
## 0.1.104 - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
|
||||
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
|
||||
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
||||
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
||||
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
||||
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
|
||||
|
||||
### Improved
|
||||
|
||||
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
|
||||
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
|
||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
|
||||
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
|
||||
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
|
||||
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
|
||||
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
|
||||
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
|
||||
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
|
||||
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
||||
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
||||
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
||||
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
|
||||
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
65
CLAUDE.md
65
CLAUDE.md
@@ -21,34 +21,38 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -91,10 +95,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- New fields: `.optional()` with a sensible default.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
||||
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
||||
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM ${NODE_IMAGE}
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
if [ -n "${PASEO_VERSION:-}" ]; then \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
fi; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
@@ -29,13 +52,14 @@ RUN set -eux; \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
@@ -66,7 +90,7 @@ RUN set -eux; \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY rootfs/ /
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
PASEO_WEB_UI_ENABLED=true \
|
||||
PASEO_LOG_FORMAT=json \
|
||||
PASEO_LOG_LEVEL=info \
|
||||
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||
CODEX_HOME=/home/paseo/.codex \
|
||||
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||
node --check "$server_entry"
|
||||
|
||||
RUN set -eux; \
|
||||
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||
groupmod --new-name paseo "$existing_group"; \
|
||||
elif [ -z "$existing_group" ]; then \
|
||||
groupadd --gid 1000 paseo; \
|
||||
fi; \
|
||||
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||
elif [ -z "$existing_user" ]; then \
|
||||
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||
fi; \
|
||||
mkdir -p \
|
||||
/workspace \
|
||||
"$PASEO_HOME" \
|
||||
"$CLAUDE_CONFIG_DIR" \
|
||||
"$CODEX_HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$XDG_STATE_HOME" \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 6767
|
||||
VOLUME ["/home/paseo"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||
@@ -71,13 +71,21 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**:
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
||||
|
||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
||||
typed `entries` list.
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
69
docs/browser-capture-harness.md
Normal file
69
docs/browser-capture-harness.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Browser Capture Harness
|
||||
|
||||
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
||||
It validates the compositor behavior that unit tests cannot see:
|
||||
|
||||
- the resident automation `<webview>` starts in the production parking state;
|
||||
- the parked guest remains paintable and has a copyable viewport frame;
|
||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||
stacking changes;
|
||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||
by retrying until the frame appears;
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination.
|
||||
|
||||
Run it with the repo Electron:
|
||||
|
||||
```bash
|
||||
npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Run the browser automation fixture with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
||||
|
||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||
focus; a normal Electron app launch can still activate the app and steal focus.
|
||||
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
||||
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
||||
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
||||
the compositor only needs visible inactive windows, and harness runs must not steal
|
||||
focus from the person using the machine.
|
||||
|
||||
The harness writes PNG evidence and `results.json` to:
|
||||
|
||||
```text
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||
is usually saved as 2560x1600.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
||||
overlap at `left:0`, `top:0`.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
invalidates before each attempt, and retries known first-frame failures within the
|
||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
@@ -84,6 +84,10 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
| `MiniMax-M3` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
@@ -457,6 +457,37 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
}
|
||||
```
|
||||
|
||||
ACP agents execute filesystem and terminal operations in their own environment
|
||||
by default. To let a compliant agent delegate those operations to Paseo instead,
|
||||
enable the corresponding client capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"local-agent": {
|
||||
"extends": "acp",
|
||||
"label": "Local Agent",
|
||||
"command": ["local-agent", "acp"],
|
||||
"params": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": true,
|
||||
"writeTextFile": true
|
||||
},
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only enable capabilities Paseo should execute. When the agent and Paseo run in
|
||||
different environments, configure equivalent absolute workspace paths before
|
||||
delegating filesystem or terminal operations to Paseo.
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
@@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
## Store Surface Rules
|
||||
|
||||
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -72,6 +72,10 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
||||
|
||||
### React render profiling
|
||||
|
||||
The app has a gated React render profiler in
|
||||
@@ -169,11 +173,33 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Agent Tool Catalog Measurement
|
||||
|
||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
||||
|
||||
```bash
|
||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
||||
for machine-readable output.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
@@ -277,6 +303,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
||||
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
|
||||
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
The official image:
|
||||
|
||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||
or from source-built workspace tarballs for beta images
|
||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -t paseo:local docker/base
|
||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
||||
```
|
||||
|
||||
To bake a specific published npm version:
|
||||
To assert the source tree version while building:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
docker/base
|
||||
-f docker/base/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
||||
from the checked-out source tree and publishes only the exact prerelease image
|
||||
tag for prerelease versions.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
|
||||
@@ -47,18 +47,27 @@ dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||
leaf workspace URL.
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
||||
Do not make the caller branch on its current route.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen, or Expo Router can append
|
||||
extra hidden workspace deck entries.
|
||||
root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
Hidden host routes may keep their local params while an app-wide route is
|
||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||
only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
@@ -87,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
@@ -112,8 +133,7 @@ Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did an app-wide route return to a workspace? Use
|
||||
`navigateToHostWorkspaceRoute()`.
|
||||
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
|
||||
@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||
until then prefer copying the closest file and trimming.
|
||||
|
||||
## Popover width contract
|
||||
|
||||
Combobox desktop popovers are never narrower than their trigger, and they grow
|
||||
with content up to a ceiling that is never below the trigger:
|
||||
|
||||
```ts
|
||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
||||
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
||||
```
|
||||
|
||||
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
||||
trigger still wins when it is wider. Changing this default requires re-verifying
|
||||
every consumer listed here.
|
||||
|
||||
Consumers: `composer/agent-controls/mode-control.tsx`,
|
||||
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
||||
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
||||
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
||||
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
||||
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
||||
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
||||
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
||||
|
||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||
|
||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||
|
||||
98
docs/forms.md
Normal file
98
docs/forms.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Forms
|
||||
|
||||
The paved road for building forms in the app. The schedule form is the golden
|
||||
example; when building or fixing any form, copy its shape, not the shape of
|
||||
whatever screen you happen to be near.
|
||||
|
||||
Golden example files:
|
||||
|
||||
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
|
||||
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
|
||||
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
|
||||
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
|
||||
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
|
||||
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
|
||||
|
||||
## The form model
|
||||
|
||||
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
|
||||
|
||||
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
|
||||
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
|
||||
AND display from the snapshot — never from a previous instance.
|
||||
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
|
||||
Derived state (disclosure, canSubmit, displays) is recomputed inside the
|
||||
model on every publish.
|
||||
- `close()` destroys the instance. `subscribe`/`getState` feed one
|
||||
`useSyncExternalStore` in the component.
|
||||
|
||||
The component renders state and dispatches intent. That is all it does.
|
||||
|
||||
### Lifecycle rules (each one killed a real shipped bug)
|
||||
|
||||
1. **Fresh mount per open.** The sheet returns `null` when not visible and
|
||||
mounts the open form with a `key` derived from mode + record identity.
|
||||
A long-lived component instance shared across create/edit is how edit
|
||||
contaminated create.
|
||||
2. **Construct the model ONCE per mount** — `useState(() => openXxxForm(snapshot))`.
|
||||
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
|
||||
on live data (projects, hosts, preferences), and any background churn — e.g.
|
||||
a scheduled run creating a workspace — would reconstruct the model and wipe
|
||||
the user's in-progress input.
|
||||
3. **Late data is an explicit model input, not a reconstruction.**
|
||||
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
|
||||
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
|
||||
effects. Input plumbing is fine; orchestration effects are not — the sheet
|
||||
itself has zero `useEffect`/`useRef`, and that is the target for every form.
|
||||
4. **Resolution is explicit model state, per host** (`idle | pending |
|
||||
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
||||
state you can render, not an effect race.
|
||||
5. **Displays are owned state.** The selected option's label is captured at
|
||||
selection/seed time (`setProject(value, display)`), never re-derived from a
|
||||
live options list — list churn must not flicker or blank a selection.
|
||||
6. **Disclosure is derived in the model** from user intent
|
||||
(host → project → model → thinking/mode), so fields cannot pop in from
|
||||
cache timing.
|
||||
|
||||
## Form kit
|
||||
|
||||
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
|
||||
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
|
||||
states) is owned by `components/ui/control-geometry.ts` — controls never
|
||||
declare their own, and screens never nudge global component styles to align
|
||||
a row.
|
||||
- The form declares one size for all fields: `sm` on desktop, `md` compact
|
||||
(`useIsCompactFormFactor`).
|
||||
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
|
||||
(isolation on a non-git project — same gating as New Workspace), not rendered
|
||||
disabled with an explanation. Disabled-with-a-reason `hint` is only for
|
||||
transient states the user can resolve.
|
||||
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
|
||||
exact string; validation errors are the exception. State a fact (like the
|
||||
timezone) once — never in a preview line AND a helper line.
|
||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
||||
|
||||
## Data gating
|
||||
|
||||
Aggregate hooks return a discriminated load state:
|
||||
|
||||
```ts
|
||||
type AggregateLoadState<T> =
|
||||
| { status: "connecting" } // an answer may still be pending
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; data: T[] };
|
||||
```
|
||||
|
||||
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
|
||||
before hosts connected is `connecting`, not empty. Query keys carry real fetch
|
||||
inputs (host set, connection statuses), never synthetic version counters.
|
||||
|
||||
## Anti-patterns (reject in review on sight)
|
||||
|
||||
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
|
||||
- One mounted form instance serving create and edit.
|
||||
- `useMemo`-keyed model construction on live-data identity.
|
||||
- Selected labels derived from live query lists.
|
||||
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
|
||||
- Conditional mounting of hint/error rows that shifts layout (subtext renders
|
||||
only when present, but the pattern for that lives in `Field`, not ad hoc).
|
||||
@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
|
||||
104
docs/mobile-panels.md
Normal file
104
docs/mobile-panels.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Mobile panels
|
||||
|
||||
Compact layouts have three mutually exclusive destinations:
|
||||
|
||||
- `agent-list` on the left
|
||||
- `agent` in the center
|
||||
- `file-explorer` on the right
|
||||
|
||||
They are one interaction, not two independent drawers. The implementation lives in
|
||||
`packages/app/src/mobile-panels/`.
|
||||
|
||||
## Ownership
|
||||
|
||||
React/Zustand owns the durable intent:
|
||||
|
||||
```ts
|
||||
interface MobilePanelSelection {
|
||||
target: "agent-list" | "agent" | "file-explorer";
|
||||
revision: number;
|
||||
}
|
||||
```
|
||||
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
The UI worklet owns transient motion:
|
||||
|
||||
- one normalized position (`-1` left, `0` center, `1` right)
|
||||
- the current motion target
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
|
||||
## Why one position
|
||||
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
- a panel and its backdrop disagreeing
|
||||
- left and right drawers both claiming to be open
|
||||
- a width-sync effect resetting an active drag
|
||||
- one animation context settling a transition owned by the other
|
||||
|
||||
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
||||
effect.
|
||||
|
||||
## Ordering and interruption
|
||||
|
||||
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
||||
while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||
its target and revision still match the canonical command.
|
||||
|
||||
Manual gesture arbitration has two phases:
|
||||
|
||||
1. Before activation, determine whether horizontal intent may begin.
|
||||
2. After activation, stop running begin checks and let the active revision own updates.
|
||||
|
||||
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
||||
definition, no longer eligible to begin.
|
||||
|
||||
## Integration rules
|
||||
|
||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||
styles in sidebar components.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||
sequence there whenever ownership or ordering changes.
|
||||
@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
|
||||
done
|
||||
```
|
||||
|
||||
### Android audio focus interruptions
|
||||
|
||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||
|
||||
```bash
|
||||
adb shell am start -n sh.paseo/.MainActivity
|
||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||
adb emu gsm call 5551234
|
||||
# Foreground Paseo while the call is still ringing.
|
||||
```
|
||||
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
|
||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `which opencode`: `opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ Anyone who builds software:
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
|
||||
42
docs/protocol-validation.md
Normal file
42
docs/protocol-validation.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Protocol Validation
|
||||
|
||||
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
||||
|
||||
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
||||
|
||||
## Runtime Path
|
||||
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||
|
||||
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
||||
|
||||
## Codegen Ownership
|
||||
|
||||
The protocol package owns generation.
|
||||
|
||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||
|
||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||
|
||||
## Regression Tests
|
||||
|
||||
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
||||
|
||||
- discriminated-union branch output must propagate `.default()` fields
|
||||
- current sequential item routing must accept `tool_call`-like status branches
|
||||
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
||||
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
||||
|
||||
## Schema Purity
|
||||
|
||||
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
||||
|
||||
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
||||
|
||||
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
||||
@@ -18,13 +18,17 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ npm run release:patch
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
@@ -281,8 +283,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
|
||||
@@ -115,6 +115,8 @@ App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
|
||||
@@ -9,6 +9,11 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
@@ -90,6 +90,19 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||
|
||||
```tsx
|
||||
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||
<View style={styles.row} />;
|
||||
|
||||
// Right: one registered style identity reaches the native view.
|
||||
<View style={sharedStyles.row} />;
|
||||
```
|
||||
|
||||
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -21797,9 +21797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28563,9 +28563,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35069,6 +35069,26 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-aot": {
|
||||
"version": "0.20.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
|
||||
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"get-tsconfig": "^4.14.0",
|
||||
"jiti": "^2.7.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"unplugin": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"zod-aot": "dist/cli/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
@@ -35132,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36150,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/server": "0.1.102",
|
||||
"@getpaseo/client": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/server": "0.1.107",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36401,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/relay": "0.1.107",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36415,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36658,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37554,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37786,19 +37806,20 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38016,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/highlight": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"@getpaseo/client": "0.1.107",
|
||||
"@getpaseo/highlight": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/relay": "0.1.107",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38561,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -58,7 +58,7 @@
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
clickToolCallBesideScrollToBottomButton,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
@@ -37,6 +38,91 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -120,6 +206,37 @@ test.describe("Agent stream UI", () => {
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-button-hit-area-",
|
||||
title: "Scroll button hit area",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Stream enough content to exercise the scroll button hit area.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const hitArea = await clickToolCallBesideScrollToBottomButton(page);
|
||||
|
||||
expect(hitArea).toEqual({
|
||||
outsideButton: true,
|
||||
toolCallReceivesPointer: true,
|
||||
withinButtonBand: true,
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
@@ -153,6 +153,13 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
@@ -351,7 +358,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
@@ -384,10 +391,28 @@ test.describe("Composer autocomplete", () => {
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await openSettingsThenBackToWorkspace(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: third.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
|
||||
@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectFlatFileList(page);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("changes-toggle-whitespace").click();
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectFlatFileList(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
@@ -10,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -20,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
@@ -50,7 +48,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -74,6 +72,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("opens a project from a fuzzy directory-name search", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPickerFixture.fuzzyQuery);
|
||||
|
||||
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 30_000 });
|
||||
await suggestion.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -81,7 +99,7 @@ test.describe("Project picker search", () => {
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill("paseo-loading-state-no-match");
|
||||
|
||||
@@ -138,17 +156,21 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import {
|
||||
createProjectPickerFixture,
|
||||
removeProjectPickerFixture,
|
||||
type ProjectPickerFixture,
|
||||
} from "./helpers/project-picker-fixture";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||
rememberProjectId: (projectId: string) => void;
|
||||
}
|
||||
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -57,7 +73,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
@@ -73,12 +89,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
||||
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
{
|
||||
daemon: testDaemon,
|
||||
preferences: createAgentPreferences,
|
||||
seedNonce,
|
||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||
},
|
||||
);
|
||||
|
||||
await provide();
|
||||
@@ -92,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await provide({
|
||||
...fixture,
|
||||
rememberProjectId: (openedProjectId) => {
|
||||
projectId = openedProjectId;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
await removeProjectPickerFixture(client, fixture, projectId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await resource.removeDirectory();
|
||||
}
|
||||
}
|
||||
},
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
|
||||
@@ -124,6 +124,111 @@ export async function scrollChatAwayFromBottom(
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{
|
||||
outsideButton: boolean;
|
||||
toolCallReceivesPointer: boolean;
|
||||
withinButtonBand: boolean;
|
||||
}> {
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" });
|
||||
await expect(scrollToBottomButton).toBeVisible();
|
||||
|
||||
const buttonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull();
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
expect(
|
||||
candidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds,
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
visibleToolCallBounds,
|
||||
"Expected aligned tool-call badge to remain visible",
|
||||
).not.toBeNull();
|
||||
const finalButtonBounds = alignedButtonBounds!;
|
||||
const finalToolCallBounds = visibleToolCallBounds!;
|
||||
|
||||
const clickPoint = {
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
const hitArea = {
|
||||
clickPoint,
|
||||
outsideButton:
|
||||
clickPoint.x < finalButtonBounds.x ||
|
||||
clickPoint.x > finalButtonBounds.x + finalButtonBounds.width,
|
||||
toolCallReceivesPointer,
|
||||
withinButtonBand:
|
||||
clickPoint.y >= finalButtonBounds.y &&
|
||||
clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height,
|
||||
};
|
||||
await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y);
|
||||
return {
|
||||
outsideButton: hitArea.outsideButton,
|
||||
toolCallReceivesPointer: hitArea.toolCallReceivesPointer,
|
||||
withinButtonBand: hitArea.withinButtonBand,
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
dialogOpenResult?: string | string[] | null;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
@@ -96,6 +97,7 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
@@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
open: async (options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogOpenCalls.push(options);
|
||||
return cfg.dialogOpenResult ?? null;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
@@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
window.__capturedDialogOpenCalls = [];
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
||||
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
61
packages/app/e2e/helpers/no-truncation.ts
Normal file
61
packages/app/e2e/helpers/no-truncation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
interface TruncatedLabel {
|
||||
text: string;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
}
|
||||
|
||||
export async function expectNoTruncation(locator: Locator): Promise<void> {
|
||||
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
|
||||
function isVisible(element: HTMLElement): boolean {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
function directText(element: HTMLElement): string {
|
||||
return Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const checked = new Set<HTMLElement>();
|
||||
const failures: TruncatedLabel[] = [];
|
||||
|
||||
for (const root of elements) {
|
||||
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
|
||||
continue;
|
||||
}
|
||||
checked.add(candidate);
|
||||
|
||||
const text = directText(candidate);
|
||||
if (!text) continue;
|
||||
if (candidate.scrollWidth <= candidate.clientWidth) continue;
|
||||
|
||||
failures.push({
|
||||
text,
|
||||
scrollWidth: candidate.scrollWidth,
|
||||
clientWidth: candidate.clientWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
});
|
||||
|
||||
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
|
||||
}
|
||||
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SeedDaemonClient } from "./seed-client";
|
||||
|
||||
export interface ProjectPickerFixture {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
fuzzyQuery: string;
|
||||
}
|
||||
|
||||
interface ProjectPickerFixtureResource {
|
||||
fixture: ProjectPickerFixture;
|
||||
removeDirectory: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
|
||||
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
|
||||
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
|
||||
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
|
||||
return {
|
||||
fixture: {
|
||||
projectPath,
|
||||
projectName: path.basename(projectPath),
|
||||
fuzzyQuery: `psodfzt${nonce}`,
|
||||
},
|
||||
removeDirectory: () => rm(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeProjectPickerFixture(
|
||||
client: SeedDaemonClient,
|
||||
fixture: ProjectPickerFixture,
|
||||
knownProjectId: string | null = null,
|
||||
): Promise<void> {
|
||||
let projectId = knownProjectId;
|
||||
if (!projectId) {
|
||||
const lookup = await client.addProject(fixture.projectPath);
|
||||
projectId = lookup.project?.projectId ?? null;
|
||||
if (!projectId) {
|
||||
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
|
||||
}
|
||||
}
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
||||
// rpc_error shape the daemon emits for failed async session requests.
|
||||
export async function installReadTransportFailure(
|
||||
page: Page,
|
||||
): Promise<{ allowRecovery: () => void }> {
|
||||
let shouldFailReads = true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "read_project_config_request",
|
||||
error: "Test read transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
allowRecovery() {
|
||||
shouldFailReads = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
|
||||
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
||||
if (input.total > 1) {
|
||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
||||
// either role by accessible name.
|
||||
function questionOption(page: Page, option: string) {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
|
||||
}
|
||||
|
||||
// The multi-question nav renders as a tablist; each question is a tab.
|
||||
function questionNavTab(page: Page, input: { index: number; total: number }) {
|
||||
return page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await questionOption(page, option).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
await questionNavTab(page, input).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionNavigationEnabled(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toBeEnabled();
|
||||
await expect(questionNavTab(page, input)).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
|
||||
@@ -154,6 +154,10 @@ export async function launchAgent(input: {
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
providerConfig?: {
|
||||
model?: string;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
@@ -180,6 +184,7 @@ export async function launchAgent(input: {
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
...input.providerConfig,
|
||||
cwd: input.cwd,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
|
||||
284
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
284
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
import { type SeededWorkspace } from "./seed-client";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
export const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FakeScheduleSummary {
|
||||
id: string;
|
||||
name: string | null;
|
||||
prompt: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
status: "active";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
pausedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
maxRuns: number | null;
|
||||
}
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildFakeProviderEntries(nowIso: string) {
|
||||
return [
|
||||
{
|
||||
provider: "mock",
|
||||
label: "Mock",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
fetchedAt: nowIso,
|
||||
models: [
|
||||
{
|
||||
provider: "mock",
|
||||
id: FAKE_HOST_MODEL_ID,
|
||||
label: FAKE_HOST_MODEL_LABEL,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [{ id: "load-test", label: "Load test" }],
|
||||
defaultModeId: "load-test",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
|
||||
const parsed = parseJson(message);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope = parsed as { type?: string; message?: SessionRequest };
|
||||
if (envelope.type !== "session" || !envelope.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.message;
|
||||
}
|
||||
|
||||
function getRequestId(request: SessionRequest): string {
|
||||
return typeof request.requestId === "string" ? request.requestId : "fake-request";
|
||||
}
|
||||
|
||||
export async function buildFakeScheduleHostWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<FakeScheduleHostWorkspace> {
|
||||
const workspaceList = await workspace.client.fetchWorkspaces({
|
||||
filter: { projectId: workspace.projectId },
|
||||
});
|
||||
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
|
||||
if (!baseWorkspace) {
|
||||
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
|
||||
}
|
||||
|
||||
const projectId = `${workspace.projectId}-fake-host`;
|
||||
const cwd = `${workspace.repoPath}-fake-host`;
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
id: `${baseWorkspace.id}-fake-host`,
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
projectRootPath: cwd,
|
||||
workspaceDirectory: cwd,
|
||||
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
project: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installFakeScheduleHost(input: {
|
||||
page: Page;
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
schedules?: FakeScheduleSummary[];
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
const parsed = parseJson(message);
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
|
||||
ws.send(
|
||||
buildSessionMessage("status", {
|
||||
status: "server_info",
|
||||
serverId: input.serverId,
|
||||
hostname: "fake-schedule-host",
|
||||
version: "0.0.0-e2e",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = readSessionRequest(message);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = getRequestId(request);
|
||||
const now = Date.now();
|
||||
const nowIso = new Date(now).toISOString();
|
||||
switch (request.type) {
|
||||
case "ping":
|
||||
ws.send(
|
||||
buildSessionMessage("pong", {
|
||||
requestId,
|
||||
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
|
||||
serverReceivedAt: now,
|
||||
serverSentAt: now,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_workspaces_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_workspaces_response", {
|
||||
requestId,
|
||||
entries: [input.workspace],
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
requestId,
|
||||
entries: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "get_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("get_providers_snapshot_response", {
|
||||
requestId,
|
||||
entries: buildFakeProviderEntries(nowIso),
|
||||
generatedAt: nowIso,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "refresh_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("refresh_providers_snapshot_response", {
|
||||
requestId,
|
||||
acknowledged: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "schedule/list":
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: input.schedules ?? [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function addFakeScheduleHostAndReload(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: string;
|
||||
}): Promise<void> {
|
||||
const host = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await input.page.evaluate(
|
||||
({ seededHost, keys }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||
nextRegistry.push(seededHost);
|
||||
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
|
||||
|
||||
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
|
||||
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
|
||||
? JSON.parse(rawExtraHosts)
|
||||
: [];
|
||||
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||
nextExtraHosts.push(seededHost);
|
||||
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
extraHosts: EXTRA_HOSTS_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await input.page.reload();
|
||||
}
|
||||
@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
|
||||
86
packages/app/e2e/helpers/settled.ts
Normal file
86
packages/app/e2e/helpers/settled.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
interface SettledOptions {
|
||||
timeout?: number;
|
||||
durationMs?: number;
|
||||
intervalMs?: number;
|
||||
heightTolerance?: number;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
text: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_MS = 1_500;
|
||||
const DEFAULT_INTERVAL_MS = 100;
|
||||
const DEFAULT_HEIGHT_TOLERANCE = 1;
|
||||
|
||||
async function sample(locator: Locator): Promise<Sample> {
|
||||
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
|
||||
if (!box) {
|
||||
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
|
||||
}
|
||||
return { text: text ?? "", height: box.height };
|
||||
}
|
||||
|
||||
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
|
||||
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
const samples: Sample[] = [];
|
||||
const deadline = Date.now() + durationMs;
|
||||
|
||||
while (Date.now() <= deadline || samples.length < 2) {
|
||||
samples.push(await sample(locator));
|
||||
await locator.page().waitForTimeout(intervalMs);
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function assertStableHeight(samples: Sample[], tolerance: number): void {
|
||||
const heights = samples.map((entry) => entry.height);
|
||||
const minHeight = Math.min(...heights);
|
||||
const maxHeight = Math.max(...heights);
|
||||
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
|
||||
tolerance,
|
||||
);
|
||||
}
|
||||
|
||||
function assertSettledText(samples: Sample[]): void {
|
||||
let stableText: string | null = null;
|
||||
let previousText: string | null = null;
|
||||
|
||||
for (const entry of samples) {
|
||||
if (stableText !== null && entry.text !== stableText) {
|
||||
throw new Error(
|
||||
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (previousText === entry.text) {
|
||||
stableText = entry.text;
|
||||
}
|
||||
previousText = entry.text;
|
||||
}
|
||||
|
||||
if (stableText === null) {
|
||||
throw new Error("Text did not reach a stable value during the settledness window.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
|
||||
const samples = await collectSamples(locator, options);
|
||||
assertSettledText(samples);
|
||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||
}
|
||||
|
||||
export async function expectStableHeight(
|
||||
locator: Locator,
|
||||
options?: SettledOptions,
|
||||
): Promise<void> {
|
||||
const samples = await collectSamples(locator, options);
|
||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||
}
|
||||
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
@@ -91,6 +92,10 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||
await modeControl.click();
|
||||
|
||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoTruncation(popup);
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
@@ -105,6 +110,19 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
||||
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
|
||||
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
|
||||
await thinkingControl.click();
|
||||
|
||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoTruncation(popup);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
@@ -158,6 +176,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectThinkingOptionsFit(page);
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
@@ -105,6 +106,54 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
const otherProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "aa-new-workspace-archive-other-",
|
||||
});
|
||||
const rememberedProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "zz-new-workspace-archive-remembered-",
|
||||
});
|
||||
const serverId = getServerId();
|
||||
const draftText = "keep this new workspace draft";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId,
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page
|
||||
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await composer.fill(draftText);
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
|
||||
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
|
||||
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
} finally {
|
||||
await otherProject.cleanup();
|
||||
await rememberedProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -166,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
@@ -63,7 +63,11 @@ test.describe("New workspace isolation memory", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
hasGithubAuth,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
@@ -209,6 +211,53 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
const emptyServerId = "empty-new-workspace-host";
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: emptyServerId,
|
||||
label: "Empty host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
const projectTrigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await projectTrigger.click();
|
||||
await page.getByPlaceholder("Search projects").fill("no matching project");
|
||||
await expect(page.getByTestId("new-workspace-project-picker-add-project")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByTestId(`new-workspace-host-picker-option-${emptyServerId}`).click();
|
||||
await expect(projectTrigger).toContainText("Choose project");
|
||||
await projectTrigger.click();
|
||||
|
||||
const addProject = page.getByTestId("new-workspace-project-picker-add-project");
|
||||
await expect(addProject).toContainText("Add project");
|
||||
await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/);
|
||||
await addProject.click();
|
||||
|
||||
await expect(page.getByTestId("project-picker-input")).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
||||
|
||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
test("Browse owns Enter without opening the active typed path", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPickerFixture.projectPath);
|
||||
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.press("Enter");
|
||||
|
||||
const dialogOptions = await waitForDirectoryDialog(page);
|
||||
expect(dialogOptions).toEqual({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
await expect(input).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectPickerFixture.projectName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
// Reject read_project_config_request calls until the user clicks Reload.
|
||||
// This keeps automatic reconnect refetches from racing past the callout.
|
||||
const transportFailure = await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
@@ -292,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
||||
transportFailure.allowRecovery();
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
|
||||
84
packages/app/e2e/provider-subagents.real.spec.ts
Normal file
84
packages/app/e2e/provider-subagents.real.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
import { openSubagentsTrack } from "./helpers/subagents";
|
||||
|
||||
interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const cases: ProviderSubagentCase[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
"Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
prompt:
|
||||
'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.',
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
prompt:
|
||||
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider subagent timelines", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of cases) {
|
||||
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openSubagentsTrack(page);
|
||||
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||
await rows.first().click();
|
||||
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
238
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
238
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
FAKE_HOST_MODEL_ID,
|
||||
FAKE_HOST_MODEL_LABEL,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
|
||||
return result.schedule.id;
|
||||
}
|
||||
|
||||
function ignoreScheduleDeleteError(): void {}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
await (workspace.client as unknown as ScheduleSeedClient)
|
||||
.scheduleDelete({ id })
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
type FakeScheduleHostSchedule = NonNullable<
|
||||
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
|
||||
>[number];
|
||||
|
||||
function buildFakeHostSchedule(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}): FakeScheduleHostSchedule {
|
||||
const now = "2026-07-01T00:00:00.000Z";
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
prompt: "Run on the secondary host.",
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
model: FAKE_HOST_MODEL_ID,
|
||||
modeId: "load-test",
|
||||
title: input.name,
|
||||
},
|
||||
},
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nextRunAt: now,
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
const scheduleId = "fake-host-schedule";
|
||||
const scheduleName = "Secondary host schedule";
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
schedules: [
|
||||
buildFakeHostSchedule({
|
||||
id: scheduleId,
|
||||
name: scheduleName,
|
||||
cwd: String(fakeHost.workspace.workspaceDirectory),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
|
||||
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
});
|
||||
|
||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Pristine create ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
415
packages/app/e2e/schedules-project-target.spec.ts
Normal file
415
packages/app/e2e/schedules-project-target.spec.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
interface ScheduleListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
cadence?: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: string;
|
||||
config?: {
|
||||
cwd?: string;
|
||||
archiveOnFinish?: boolean;
|
||||
isolation?: "local" | "worktree";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: /select model/i }).click();
|
||||
const popup = page.getByTestId("combobox-desktop-container");
|
||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||
const searchInput = page.getByTestId("model-search-input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(label);
|
||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (schedule) {
|
||||
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScheduleCreatedForProject(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
cadenceExpression: string;
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||
expect(schedule).toEqual(
|
||||
expect.objectContaining({
|
||||
name: input.name,
|
||||
cadence: expect.objectContaining({
|
||||
type: "cron",
|
||||
expression: input.cadenceExpression,
|
||||
}),
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectScheduleKnobs(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
archiveOnFinish: boolean;
|
||||
isolation: "local" | "worktree";
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||
expect(schedule).toEqual(
|
||||
expect.objectContaining({
|
||||
name: input.name,
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
archiveOnFinish: input.archiveOnFinish,
|
||||
isolation: input.isolation,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openNewScheduleSheet(page: Page): Promise<void> {
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
}
|
||||
|
||||
function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
|
||||
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
|
||||
const backdrop = bottomSheetBackdrop(page);
|
||||
await expect(backdrop).toBeVisible({ timeout: 10_000 });
|
||||
await expect(async () => {
|
||||
const box = await backdrop.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
||||
}
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
|
||||
}
|
||||
|
||||
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (!schedule) {
|
||||
throw new Error(`Expected schedule named ${name} to exist`);
|
||||
}
|
||||
return schedule.id;
|
||||
}
|
||||
|
||||
test.describe("Schedules project target", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openNewScheduleSheet(page);
|
||||
await dismissScheduleSheetWithBackdrop(page);
|
||||
await expectScheduleSheetClosedAndStable(page);
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openNewScheduleSheet(page);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expectScheduleSheetClosedAndStable(page);
|
||||
});
|
||||
|
||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByRole("button", { name: "Schedules" }).click();
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
await expect(page).not.toHaveURL(/\/h\//);
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
||||
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||
await page.getByTestId("schedule-cadence-preset-daily-9").click();
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
|
||||
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleCreatedForProject({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
cadenceExpression: "0 9 * * *",
|
||||
});
|
||||
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
});
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
|
||||
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
||||
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await hostFilterTrigger.click();
|
||||
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(hostFilterPopup);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears the selected project and model when the host changes", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const serverId = getServerId();
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectTrigger).toHaveCount(0);
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await hostTrigger.click();
|
||||
await page.getByTestId(`schedule-host-option-${serverId}`).click();
|
||||
await expect(projectTrigger).toBeVisible();
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await expectSettled(hostTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
await expectSettled(modeTrigger);
|
||||
|
||||
await hostTrigger.click();
|
||||
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
||||
await expect(hostTrigger).toContainText("Fake host");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
await expectSettled(hostTrigger);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(/select model/i);
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Knob schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("schedule-isolation-trigger").click();
|
||||
await page.getByTestId("schedule-isolation-worktree").click();
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
|
||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||
await page.getByTestId("schedule-cadence-preset-every-hour").click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleKnobs({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
archiveOnFinish: false,
|
||||
isolation: "worktree",
|
||||
});
|
||||
|
||||
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
|
||||
await page.getByTestId(`schedule-row-${scheduleId}`).click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("schedule-isolation-trigger").click();
|
||||
await page.getByTestId("schedule-isolation-local").click();
|
||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleKnobs({
|
||||
workspace,
|
||||
name: scheduleName,
|
||||
archiveOnFinish: true,
|
||||
isolation: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
||||
test.describe("Model B sidebar shape", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||
// none, since worktree creation needs a git checkout.
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The global new-workspace button is the universal entry — present for both
|
||||
// kinds regardless of their per-row affordance.
|
||||
|
||||
26
packages/app/e2e/workspace-archive-shortcut.spec.ts
Normal file
26
packages/app/e2e/workspace-archive-shortcut.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -256,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
|
||||
|
||||
await expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree archive", () => {
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
|
||||
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
||||
// crawler behavior depends on the host Watchman build/capabilities, while the
|
||||
// node crawler is the path used when Watchman is absent.
|
||||
config.resolver.useWatchman = false;
|
||||
|
||||
const escapedAppSrcRoot = appSrcRoot
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.107",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,6 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
|
||||
@@ -152,6 +152,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
@@ -98,6 +99,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const active = useRetainedPanelActive();
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingLoader}>
|
||||
@@ -106,6 +108,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
active={active}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
@@ -52,6 +51,7 @@ import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
@@ -87,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
@@ -228,13 +228,20 @@ export interface AgentStreamViewHandle {
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
agent: AgentScreenAgent;
|
||||
context: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
streamHead?: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
readOnly?: boolean;
|
||||
historyPagination?: {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
@@ -306,18 +313,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{
|
||||
agentId,
|
||||
serverId,
|
||||
agent,
|
||||
context,
|
||||
streamItems,
|
||||
streamHead: providedStreamHead,
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
readOnly = false,
|
||||
historyPagination,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -336,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const streamHead = useSessionStore((state) =>
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
(state) =>
|
||||
!readOnly &&
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||
const agentHistoryPagination = useLoadOlderAgentHistory({
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -378,7 +399,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -401,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent.workspaceId) {
|
||||
if (context.workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
@@ -418,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
@@ -443,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const draftSetup = buildForkDraftSetup(context);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
@@ -465,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
const workspaceId = context.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
@@ -480,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
@@ -492,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
displayName: context.projectPlacement?.projectName,
|
||||
projectId: context.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
@@ -507,7 +528,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
@@ -519,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: agent.status,
|
||||
agentStatus: context.status,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
}, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
strategy: streamRenderStrategy,
|
||||
agentStatus: agent.status,
|
||||
agentStatus: context.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
agent.status,
|
||||
context.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
@@ -589,14 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
capabilities={agent.capabilities}
|
||||
capabilities={context.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
[context.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -633,10 +654,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
args={item.text}
|
||||
status={item.status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
defaultExpanded={autoExpandReasoning}
|
||||
forceInline={autoExpandReasoning}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[setInlineDetailsExpanded],
|
||||
[autoExpandReasoning, setInlineDetailsExpanded],
|
||||
);
|
||||
|
||||
const renderToolCallItem = useCallback(
|
||||
@@ -665,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
error={data.error}
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={agent.cwd}
|
||||
cwd={context.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
@@ -687,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
@@ -744,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -755,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = agent.status === "running";
|
||||
const showRunningTurnFooter = context.status === "running";
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
@@ -772,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -902,12 +926,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
@@ -917,8 +937,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
>
|
||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolCallSheetProvider>
|
||||
@@ -1001,6 +1021,17 @@ function bottomAnchorRouteRequestsEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function historyPaginationPropsEqual(
|
||||
left: AgentStreamViewProps["historyPagination"],
|
||||
right: AgentStreamViewProps["historyPagination"],
|
||||
): boolean {
|
||||
return (
|
||||
left?.hasOlder === right?.hasOlder &&
|
||||
left?.isLoadingOlder === right?.isLoadingOlder &&
|
||||
left?.onLoadOlder === right?.onLoadOlder
|
||||
);
|
||||
}
|
||||
|
||||
function agentStreamViewPropsEqual(
|
||||
left: AgentStreamViewProps,
|
||||
right: AgentStreamViewProps,
|
||||
@@ -1008,8 +1039,9 @@ function agentStreamViewPropsEqual(
|
||||
const reasons: string[] = [];
|
||||
if (left.agentId !== right.agentId) reasons.push("agentId");
|
||||
if (left.serverId !== right.serverId) reasons.push("serverId");
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
|
||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
||||
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
|
||||
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||
if (
|
||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||
@@ -1021,6 +1053,10 @@ function agentStreamViewPropsEqual(
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
|
||||
if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) {
|
||||
reasons.push("historyPagination");
|
||||
}
|
||||
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
|
||||
return reasons.length === 0;
|
||||
}
|
||||
@@ -1390,13 +1426,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
scrollToBottomInner: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollToBottomButton: {
|
||||
width: 48,
|
||||
|
||||
@@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -23,9 +17,8 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
@@ -34,25 +27,19 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
@@ -65,6 +52,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -81,12 +69,15 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
@@ -94,17 +85,11 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
@@ -133,14 +118,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +297,9 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
if (hasConfiguredLocalDaemonOverride()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
@@ -401,22 +388,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||
const flexStyle = { flex: 1 } as const;
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||
|
||||
function AppContainer({
|
||||
children,
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -471,28 +452,31 @@ function AppContainer({
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = (
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -501,18 +485,38 @@ function AppContainer({
|
||||
<HostChooserModal />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
);
|
||||
|
||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||
return content;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
showSidebar,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
showSidebar: boolean;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
return (
|
||||
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||
{showSidebar ? <LeftSidebar /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
@@ -522,131 +526,13 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
setOverlayPeek,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled;
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
showMobileAgentList();
|
||||
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
.failOffsetY([-10, 10])
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
touchStartY.value = touch.absoluteY;
|
||||
}
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) return;
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
const deltaY = touch.absoluteY - touchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
// The overlay is display:none while closed; reveal it for the drag.
|
||||
runOnJS(setOverlayPeek)(true);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
runOnJS(setOverlayPeek)(false);
|
||||
}),
|
||||
[
|
||||
openGestureEnabled,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
setOverlayPeek,
|
||||
handleGestureOpen,
|
||||
isGesturing,
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
);
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
{children}
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
@@ -878,7 +764,6 @@ function OpenProjectListener() {
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
@@ -889,32 +774,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
(pathname === "/open-project" ||
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
||||
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (workspaceServerId && openIntent?.kind === "agent") {
|
||||
const agentId = openIntent.agentId.trim();
|
||||
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
||||
}
|
||||
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||
}, [params.open, pathname]);
|
||||
|
||||
return (
|
||||
<AppContainer
|
||||
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
||||
chromeEnabled={shouldShowAppChrome}
|
||||
>
|
||||
{children}
|
||||
</AppContainer>
|
||||
);
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
}
|
||||
|
||||
function FaviconStatusSync() {
|
||||
@@ -922,21 +785,15 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
const stackScreenOptions = useMemo(
|
||||
() => ({
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
return (
|
||||
<Stack screenOptions={stackScreenOptions}>
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -947,12 +804,13 @@ function RootStack() {
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -968,7 +826,7 @@ function WorkspaceRouteNavigationBridge() {
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
@@ -976,7 +834,7 @@ function AppShell() {
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</MobilePanelsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -993,31 +851,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// PortalProvider must stay inside normal app-wide context providers here.
|
||||
// PortalProvider must stay inside normal app-wide context providers.
|
||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
||||
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
|
||||
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
function RootAppTree() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
@@ -1031,6 +885,18 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
@@ -33,14 +34,14 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
@@ -185,22 +187,25 @@ function WorkspaceDeck() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
const next = pruneMountedWorkspaceSelections({
|
||||
currentSelections: current,
|
||||
const nextMountedSelections = useMemo(
|
||||
() =>
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
});
|
||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeSelection]);
|
||||
}),
|
||||
[activeSelection, mountedSelections],
|
||||
);
|
||||
const renderedSelections = useMemo(
|
||||
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||
[nextMountedSelections],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||
setMountedSelections(nextMountedSelections);
|
||||
}
|
||||
}, [mountedSelections, nextMountedSelections]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
@@ -208,7 +213,7 @@ function WorkspaceDeck() {
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
{renderedSelections.map((selection) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
@@ -251,8 +256,8 @@ function WorkspaceDeckEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
<RetainedPanel
|
||||
active={isActive}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
@@ -260,7 +265,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,11 +273,4 @@ const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
10
packages/app/src/app/schedules.tsx
Normal file
10
packages/app/src/app/schedules.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||
|
||||
export default function SchedulesRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SchedulesScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import {
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
function SettingsDaemonRedirect() {
|
||||
const hosts = useHosts();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
|
||||
if (localDaemon.status === "loading") {
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
if (
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId)
|
||||
) {
|
||||
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
|
||||
}
|
||||
|
||||
return <Redirect href={buildSettingsRoute()} />;
|
||||
}
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
|
||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
if (rawSection === "daemon") {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsDaemonRedirect />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,6 +42,14 @@ export interface BrowserElementAttachment {
|
||||
} | null;
|
||||
parentChain: string[];
|
||||
children: string[];
|
||||
/** Free-text review note the user wrote about this element, if any. */
|
||||
comment?: string;
|
||||
/**
|
||||
* Cropped screenshot of the selected element, sent to the agent as an image
|
||||
* alongside the textual element context. Persisted via the attachment store;
|
||||
* referenced by id so the draft-store GC keeps it alive.
|
||||
*/
|
||||
screenshot?: AttachmentMetadata;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
|
||||
611
packages/app/src/browser-automation/handler.test.ts
Normal file
611
packages/app/src/browser-automation/handler.test.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||
|
||||
class FakeStateStorage implements StateStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
public getItem = (key: string): string | null => this.values.get(key) ?? null;
|
||||
|
||||
public setItem = (key: string, value: string): void => {
|
||||
this.values.set(key, value);
|
||||
};
|
||||
|
||||
public removeItem = (key: string): void => {
|
||||
this.values.delete(key);
|
||||
};
|
||||
|
||||
public clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonClient {
|
||||
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||
|
||||
public on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (request: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void {
|
||||
expect(type).toBe("browser.automation.execute.request");
|
||||
this.handler = handler;
|
||||
return () => {
|
||||
if (this.handler === handler) {
|
||||
this.handler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
|
||||
this.sentResponses.push(response);
|
||||
}
|
||||
|
||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||
this.handler?.(nextRequest);
|
||||
}
|
||||
|
||||
public payloadAt(index: number): BrowserAutomationResponsePayload {
|
||||
const response = this.sentResponses[index];
|
||||
if (!response) {
|
||||
throw new Error(`Missing browser automation response at index ${index}`);
|
||||
}
|
||||
return response.payload;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBrowserBridge {
|
||||
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
|
||||
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
|
||||
[];
|
||||
public readonly unregisteredWorkspaceBrowsers: string[] = [];
|
||||
public readonly clearedPartitions: string[] = [];
|
||||
public readonly activeWorkspaceBrowsers: Array<{
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
}> = [];
|
||||
public response: BrowserAutomationResponsePayload | null = null;
|
||||
public thrownError: unknown = null;
|
||||
|
||||
public executeAutomationCommand = async (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
): Promise<BrowserAutomationResponsePayload> => {
|
||||
this.executedRequests.push(request);
|
||||
if (this.thrownError) {
|
||||
throw this.thrownError;
|
||||
}
|
||||
return this.response ?? currentListTabsPayload(request.requestId);
|
||||
};
|
||||
|
||||
public registerWorkspaceBrowser = async (input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
this.registeredWorkspaceBrowsers.push(input);
|
||||
};
|
||||
|
||||
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
|
||||
this.unregisteredWorkspaceBrowsers.push(browserId);
|
||||
};
|
||||
|
||||
public clearPartition = async (browserId: string): Promise<void> => {
|
||||
this.clearedPartitions.push(browserId);
|
||||
};
|
||||
|
||||
public setWorkspaceActiveBrowser = async (input: {
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
this.activeWorkspaceBrowsers.push(input);
|
||||
};
|
||||
}
|
||||
|
||||
class FakeResidentBrowser {
|
||||
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
|
||||
|
||||
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
|
||||
this.ensuredWebviews.push(input);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
class BrowserAutomationHandlerHarness {
|
||||
public readonly client = new FakeDaemonClient();
|
||||
public readonly browser = new FakeBrowserBridge();
|
||||
public readonly resident = new FakeResidentBrowser();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
public mount(
|
||||
input: {
|
||||
serverId?: string;
|
||||
host?: DesktopHostBridge | null;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
} = {},
|
||||
): void {
|
||||
this.unsubscribe = mountBrowserAutomationHandler({
|
||||
client: this.client,
|
||||
...(input.serverId ? { serverId: input.serverId } : {}),
|
||||
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
|
||||
ensureResidentBrowserWebview: this.resident.ensure,
|
||||
...(input.registrationWaitTimeoutMs !== undefined
|
||||
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
|
||||
: {}),
|
||||
...(input.registrationPollIntervalMs !== undefined
|
||||
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
public unmount(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
public receive(request: BrowserAutomationExecuteRequest): void {
|
||||
this.client.receive(request);
|
||||
}
|
||||
}
|
||||
|
||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-1",
|
||||
command: { command: "list_tabs", args: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: {
|
||||
command: "new_tab",
|
||||
args: { url: "https://example.com" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserResizeRequest(
|
||||
browserId: string,
|
||||
input: { workspaceId?: string } = {},
|
||||
): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-resize",
|
||||
agentId: "agent-1",
|
||||
workspaceId: input.workspaceId ?? "wks_workspace_a",
|
||||
command: {
|
||||
command: "resize",
|
||||
args: { browserId, width: 1024, height: 768 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-close-tab",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: {
|
||||
command: "close_tab",
|
||||
args: { browserId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||
return {
|
||||
requestId,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "list_tabs",
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||
return {
|
||||
requestId,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "list_tabs",
|
||||
tabs: currentBrowserTabs(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentBrowserTabs() {
|
||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||
browserId: browser.browserId,
|
||||
workspaceId: "wks_workspace_a",
|
||||
url: browser.url,
|
||||
title: browser.title,
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
|
||||
expect(payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: true,
|
||||
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
|
||||
});
|
||||
if (!payload.ok || payload.result.command !== "new_tab") {
|
||||
throw new Error("Expected browser_new_tab success payload");
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
|
||||
return useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function waitForRegistrationTimeout(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
const browserAutomationStorage = new FakeStateStorage();
|
||||
useBrowserStore.persist.setOptions({
|
||||
storage: createJSONStorage(() => browserAutomationStorage),
|
||||
});
|
||||
useWorkspaceLayoutStore.persist.setOptions({
|
||||
storage: createJSONStorage(() => browserAutomationStorage),
|
||||
});
|
||||
|
||||
describe("mountBrowserAutomationHandler", () => {
|
||||
beforeEach(() => {
|
||||
browserAutomationStorage.clear();
|
||||
useBrowserStore.setState({ browsersById: {} });
|
||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||
});
|
||||
|
||||
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
throw new Error("Expected workspace key");
|
||||
}
|
||||
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(openedTabs).toEqual([
|
||||
expect.objectContaining({
|
||||
target: { kind: "browser", browserId: result.browserId },
|
||||
}),
|
||||
]);
|
||||
expect(layout?.root).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "pane",
|
||||
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
|
||||
}),
|
||||
);
|
||||
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||
]);
|
||||
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
{ browserId: result.browserId, url: "https://example.com" },
|
||||
]);
|
||||
expect(browser.browser.executedRequests).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new:list_tabs",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: { command: "list_tabs", args: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.response = emptyListTabsPayload();
|
||||
browser.mount({
|
||||
serverId: "server-1",
|
||||
registrationWaitTimeoutMs: 1,
|
||||
registrationPollIntervalMs: 1,
|
||||
});
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await waitForRegistrationTimeout();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
message: expect.stringContaining("Timed out waiting for browser tab"),
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = new Error("IPC registration check failed");
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("browser_resize updates resident webview dimensions", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserResizeRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-resize",
|
||||
ok: true,
|
||||
result: {
|
||||
command: "resize",
|
||||
browserId: result.browserId,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
});
|
||||
expect(browser.browser.executedRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("browser_resize returns not found for a tab outside the request workspace", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-resize",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${result.browserId}`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
throw new Error("Expected workspace key");
|
||||
}
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-close-tab",
|
||||
ok: true,
|
||||
result: { command: "close_tab", browserId: result.browserId },
|
||||
});
|
||||
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
|
||||
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
|
||||
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
|
||||
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
|
||||
expect(currentBrowserTabs()).toEqual([]);
|
||||
});
|
||||
|
||||
test("browser_close_tab returns not found after the tab is gone", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(2)).toEqual({
|
||||
requestId: "req-close-tab",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${result.browserId}`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("non-new-tab requests send the desktop bridge response", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.response = {
|
||||
requestId: "desktop-req",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
};
|
||||
browser.mount();
|
||||
const request = browserAutomationRequest();
|
||||
|
||||
browser.receive(request);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.browser.executedRequests).toEqual([request]);
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ host: null });
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed bridge errors become failure responses", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
};
|
||||
browser.mount();
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = new Error(
|
||||
'No handler registered for "paseo:browser:execute-automation-command"',
|
||||
);
|
||||
browser.mount();
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not implemented by this app build yet.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("unsubscribe stops handling browser automation requests", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount();
|
||||
|
||||
browser.unmount();
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.browser.executedRequests).toEqual([]);
|
||||
expect(browser.client.sentResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
472
packages/app/src/browser-automation/handler.ts
Normal file
472
packages/app/src/browser-automation/handler.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
||||
import {
|
||||
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
|
||||
removeResidentBrowserWebview,
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
|
||||
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
|
||||
|
||||
interface BrowserAutomationClient {
|
||||
on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (message: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void;
|
||||
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
|
||||
}
|
||||
|
||||
export interface BrowserAutomationHandlerOptions {
|
||||
client: BrowserAutomationClient;
|
||||
serverId?: string;
|
||||
getHost?: () => DesktopHostBridge | null;
|
||||
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationHandler(
|
||||
options: BrowserAutomationHandlerOptions,
|
||||
): () => void {
|
||||
const getHost = options.getHost ?? getDesktopHost;
|
||||
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
||||
void handleBrowserAutomationRequest({
|
||||
client: options.client,
|
||||
getHost,
|
||||
request,
|
||||
serverId: options.serverId,
|
||||
ensureResidentBrowserWebview:
|
||||
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
|
||||
...(options.registrationWaitTimeoutMs !== undefined
|
||||
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
|
||||
: {}),
|
||||
...(options.registrationPollIntervalMs !== undefined
|
||||
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationDaemonClientHandler(
|
||||
client: unknown,
|
||||
options?: { serverId?: string },
|
||||
): () => void {
|
||||
return mountBrowserAutomationHandler({
|
||||
client: client as BrowserAutomationClient,
|
||||
...(options?.serverId ? { serverId: options.serverId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBrowserAutomationRequest(params: {
|
||||
client: BrowserAutomationHandlerOptions["client"];
|
||||
getHost: () => DesktopHostBridge | null;
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
client,
|
||||
getHost,
|
||||
request,
|
||||
serverId,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const browserHost = getHost()?.browser;
|
||||
const executeAutomationCommand = browserHost?.executeAutomationCommand;
|
||||
|
||||
if (request.command.command === "new_tab") {
|
||||
try {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: await openBrowserTabForRequest({
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.command.command === "resize") {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: resizeBrowserTabForRequest({ request, serverId }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.command.command === "close_tab") {
|
||||
try {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: await closeBrowserTabForRequest({
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executeAutomationCommand) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await executeAutomationCommand(request);
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeBridgePayload(request.requestId, payload),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resizeBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
}): BrowserAutomationResponsePayload {
|
||||
const { request, serverId } = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "resize" }
|
||||
>;
|
||||
const browserId = command.args.browserId;
|
||||
if (!getBrowserRecord(browserId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceId = request.workspaceId;
|
||||
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dimensions = resizeResidentBrowserWebview({
|
||||
browserId,
|
||||
width: command.args.width,
|
||||
height: command.args.height,
|
||||
});
|
||||
if (!dimensions) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "resize",
|
||||
browserId,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
}): Promise<BrowserAutomationResponsePayload> {
|
||||
const { request, serverId, browserHost } = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "close_tab" }
|
||||
>;
|
||||
const browserId = command.args.browserId;
|
||||
const workspaceId = request.workspaceId;
|
||||
const workspaceTab = serverId
|
||||
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
|
||||
: null;
|
||||
if (!workspaceTab && (!serverId || !workspaceId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot close a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
if (!workspaceTab || !getBrowserRecord(browserId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
removeResidentBrowserWebview(browserId);
|
||||
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
|
||||
await browserHost?.clearPartition?.(browserId);
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: { command: "close_tab", browserId },
|
||||
};
|
||||
}
|
||||
|
||||
function findWorkspaceBrowserTab(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
browserId: string;
|
||||
}): { workspaceKey: string; tabId: string } | null {
|
||||
if (!input.workspaceId) {
|
||||
return null;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tab = layout
|
||||
? collectAllTabs(layout.root).find((candidate) => {
|
||||
return (
|
||||
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
|
||||
);
|
||||
})
|
||||
: null;
|
||||
return tab ? { workspaceKey, tabId: tab.tabId } : null;
|
||||
}
|
||||
|
||||
async function openBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<BrowserAutomationResponsePayload> {
|
||||
const {
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "new_tab" }
|
||||
>;
|
||||
const workspaceId = request.workspaceId;
|
||||
if (!serverId || !workspaceId) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
|
||||
const url = command.args.url ?? "https://example.com";
|
||||
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
||||
kind: "browser",
|
||||
browserId,
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
const registered = await waitForBrowserRegistration({
|
||||
request,
|
||||
browserId,
|
||||
workspaceId,
|
||||
executeAutomationCommand: browserHost.executeAutomationCommand,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined
|
||||
? { pollIntervalMs: registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
if (!registered) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_timeout",
|
||||
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForBrowserRegistration(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
executeAutomationCommand: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationResponsePayload>;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
|
||||
while (Date.now() < deadline) {
|
||||
const payload = await params.executeAutomationCommand({
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: `${params.request.requestId}:list_tabs`,
|
||||
agentId: params.request.agentId,
|
||||
cwd: params.request.cwd,
|
||||
workspaceId: params.workspaceId,
|
||||
command: { command: "list_tabs", args: {} },
|
||||
});
|
||||
if (payload.ok && payload.result.command === "list_tabs") {
|
||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await delay(params.pollIntervalMs ?? 100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeBridgePayload(
|
||||
requestId: string,
|
||||
payload: BrowserAutomationResponsePayload,
|
||||
): BrowserAutomationResponsePayload {
|
||||
return { ...payload, requestId } as BrowserAutomationResponsePayload;
|
||||
}
|
||||
|
||||
function normalizeThrownBridgeError(
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
): BrowserAutomationFailurePayload {
|
||||
const typed = readTypedBrowserAutomationError(error);
|
||||
if (typed) {
|
||||
return browserAutomationFailure({ requestId, ...typed });
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered")) {
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not implemented by this app build yet.",
|
||||
});
|
||||
}
|
||||
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unknown_error",
|
||||
message: message || "Browser automation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
function readTypedBrowserAutomationError(
|
||||
value: unknown,
|
||||
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
|
||||
return null;
|
||||
}
|
||||
if (typeof record.message !== "string" || record.message.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: record.code as BrowserAutomationErrorCode,
|
||||
message: record.message,
|
||||
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function browserAutomationFailure(params: {
|
||||
requestId: string;
|
||||
code: BrowserAutomationErrorCode;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}): BrowserAutomationFailurePayload {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
ok: false,
|
||||
error: {
|
||||
code: params.code,
|
||||
message: params.message,
|
||||
retryable: params.retryable ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
@@ -178,6 +180,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
desktopScroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
@@ -214,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
...createControlGeometry(theme).controlFocusRingColor,
|
||||
},
|
||||
adaptiveInputText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -225,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -442,6 +450,7 @@ export interface AdaptiveModalSheetProps {
|
||||
header: SheetHeader;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
children: ReactNode;
|
||||
/** Sticky footer rendered below the scrollable content. */
|
||||
footer?: ReactNode;
|
||||
@@ -451,12 +460,18 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Render the themed desktop-web scrollbar over the scroll area instead of the
|
||||
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
|
||||
*/
|
||||
webScrollbar?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
children,
|
||||
footer,
|
||||
snapPoints,
|
||||
@@ -464,11 +479,16 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
webScrollbar = false,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const desktopScrollRef = useRef<ScrollView>(null);
|
||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
||||
enabled: webScrollbar && !isMobile,
|
||||
});
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
@@ -517,6 +537,20 @@ export function AdaptiveModalSheet({
|
||||
isEnabled: isMobile,
|
||||
onClose,
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
const notifyNativeModalDismiss = useCallback(() => {
|
||||
if (nativeModalDismissNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeModalDismissNotifiedRef.current = true;
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -529,12 +563,53 @@ export function AdaptiveModalSheet({
|
||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||
[desktopMaxWidth],
|
||||
);
|
||||
const desktopOverlayStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopOverlay,
|
||||
isWeb && {
|
||||
opacity: isWebClosing ? 0 : 1,
|
||||
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
||||
transitionProperty: "opacity",
|
||||
transitionTimingFunction: "ease",
|
||||
},
|
||||
],
|
||||
[isWebClosing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(onClose);
|
||||
}, [visible, isMobile, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
nativeModalDismissNotifiedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile) return;
|
||||
if (visible) {
|
||||
setShouldRenderWeb(true);
|
||||
setIsWebClosing(false);
|
||||
return;
|
||||
}
|
||||
if (!shouldRenderWeb) return;
|
||||
setIsWebClosing(true);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setShouldRenderWeb(false);
|
||||
setIsWebClosing(false);
|
||||
onDismiss?.();
|
||||
}, WEB_EXIT_DURATION_MS);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
@@ -543,7 +618,7 @@ export function AdaptiveModalSheet({
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
@@ -574,13 +649,22 @@ export function AdaptiveModalSheet({
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
ref={desktopScrollRef}
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={desktopScrollbar.onLayout}
|
||||
onScroll={desktopScrollbar.onScroll}
|
||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!webScrollbar}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{desktopScrollbar.overlay}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
@@ -589,7 +673,7 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<View style={desktopOverlayStyle} testID={testID}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.dismiss")}
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
@@ -601,7 +685,7 @@ export function AdaptiveModalSheet({
|
||||
|
||||
// On web, use portal to overlay root for consistent stacking with toasts
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
if (!visible) return null;
|
||||
if (!shouldRenderWeb) return null;
|
||||
return createPortal(desktopContent, getOverlayRoot());
|
||||
}
|
||||
|
||||
@@ -611,6 +695,7 @@ export function AdaptiveModalSheet({
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
>
|
||||
{desktopContent}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
formatAppDiagnosticHeader,
|
||||
formatDiagnosticSection,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
formatServerInfoSection,
|
||||
redactAppDiagnosticReport,
|
||||
} from "@/diagnostics/app-diagnostic-report";
|
||||
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -261,35 +261,6 @@ export function AppDiagnosticSheet({
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
||||
try {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
return {
|
||||
status: "done",
|
||||
sections: [
|
||||
formatDiagnosticSection("Desktop", [
|
||||
{ label: "Daemon status", value: status.status },
|
||||
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
||||
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
||||
{ label: "Daemon version", value: status.version ?? "unknown" },
|
||||
{ label: "Daemon home", value: status.home || "unknown" },
|
||||
{ label: "Log path", value: logs.logPath || "unknown" },
|
||||
{ label: "Error", value: status.error ?? "none" },
|
||||
]),
|
||||
[
|
||||
"Desktop daemon log tail",
|
||||
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
||||
].join("\n"),
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function collectHostDiagnosticSections(
|
||||
host: HostProfile,
|
||||
snapshot: HostRuntimeSnapshot | null,
|
||||
@@ -338,14 +309,6 @@ async function collectHostDiagnosticSections(
|
||||
}
|
||||
}
|
||||
|
||||
function indentBlock(value: string): string {
|
||||
return value
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
prepareBrowserWebview,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./browser-webview-resident";
|
||||
|
||||
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
|
||||
|
||||
function residentHost(): HTMLElement {
|
||||
const host = document.getElementById(RESIDENT_HOST_ID);
|
||||
if (!host) {
|
||||
throw new Error("Expected resident browser host");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function expectPermanentHostParking(host: HTMLElement): void {
|
||||
expect(host.style.position).toBe("fixed");
|
||||
expect(host.style.left).toBe("0px");
|
||||
expect(host.style.top).toBe("0px");
|
||||
expect(host.style.width).toBe("1px");
|
||||
expect(host.style.height).toBe("1px");
|
||||
expect(host.style.overflow).toBe("hidden");
|
||||
expect(host.style.opacity).toBe("1");
|
||||
expect(host.style.pointerEvents).toBe("none");
|
||||
expect(host.style.display).toBe("block");
|
||||
expect(host.style.visibility).toBe("visible");
|
||||
expect(host.style.transform).toBe("");
|
||||
}
|
||||
|
||||
function expectResidentWebviewParking(webview: HTMLElement): void {
|
||||
expect(webview.style.display).toBe("inline-flex");
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("1280px");
|
||||
expect(webview.style.height).toBe("800px");
|
||||
expect(webview.style.position).toBe("absolute");
|
||||
expect(webview.style.left).toBe("0px");
|
||||
expect(webview.style.top).toBe("0px");
|
||||
expect(webview.style.zIndex).toBe("0");
|
||||
}
|
||||
|
||||
describe("resident browser webviews", () => {
|
||||
afterEach(() => {
|
||||
clearResidentBrowserWebviewsForTests();
|
||||
});
|
||||
|
||||
it("parks a browser webview in the permanent paintable 1x1 host", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
visibleHost.appendChild(webview);
|
||||
document.body.appendChild(visibleHost);
|
||||
|
||||
releaseResidentBrowserWebview("browser-a", webview);
|
||||
|
||||
const host = residentHost();
|
||||
expect(visibleHost.children).toHaveLength(0);
|
||||
expect(Array.from(host.children)).toEqual([webview]);
|
||||
expect(webview.isConnected).toBe(true);
|
||||
expectPermanentHostParking(host);
|
||||
expectResidentWebviewParking(webview);
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-agent",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expect(webview?.isConnected).toBe(true);
|
||||
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
|
||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
|
||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||
"https://example.com",
|
||||
);
|
||||
expectPermanentHostParking(residentHost());
|
||||
expectResidentWebviewParking(webview as HTMLElement);
|
||||
});
|
||||
|
||||
it("normalizes an existing resident host back to permanent parking", () => {
|
||||
const staleHost = document.createElement("div");
|
||||
staleHost.id = RESIDENT_HOST_ID;
|
||||
staleHost.style.left = "-20000px";
|
||||
staleHost.style.width = "1280px";
|
||||
staleHost.style.height = "800px";
|
||||
staleHost.style.opacity = "0";
|
||||
staleHost.style.display = "none";
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-stale-host",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expectPermanentHostParking(staleHost);
|
||||
expectResidentWebviewParking(webview as HTMLElement);
|
||||
});
|
||||
|
||||
it("normalizes an existing resident webview and its stale host before reusing them", () => {
|
||||
const staleHost = document.createElement("div");
|
||||
staleHost.id = RESIDENT_HOST_ID;
|
||||
staleHost.style.left = "-20000px";
|
||||
staleHost.style.width = "1280px";
|
||||
staleHost.style.height = "800px";
|
||||
staleHost.style.opacity = "0";
|
||||
staleHost.style.display = "none";
|
||||
|
||||
const staleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(staleWebview, {
|
||||
browserId: "browser-stale-child",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
staleWebview.style.display = "none";
|
||||
staleWebview.style.width = "1px";
|
||||
staleWebview.style.height = "1px";
|
||||
staleWebview.style.position = "fixed";
|
||||
staleWebview.style.left = "-20000px";
|
||||
staleHost.appendChild(staleWebview);
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-stale-child",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
expect(webview).toBe(staleWebview);
|
||||
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
|
||||
expectPermanentHostParking(staleHost);
|
||||
expectResidentWebviewParking(staleWebview);
|
||||
});
|
||||
|
||||
it("parks resident webviews as an overlapping stack", () => {
|
||||
const firstWebview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-first",
|
||||
url: "https://example.com/first",
|
||||
});
|
||||
const secondWebview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-second",
|
||||
url: "https://example.com/second",
|
||||
});
|
||||
|
||||
const host = residentHost();
|
||||
expect(firstWebview?.parentElement).toBe(host);
|
||||
expect(secondWebview?.parentElement).toBe(host);
|
||||
expectResidentWebviewParking(firstWebview as HTMLElement);
|
||||
expectResidentWebviewParking(secondWebview as HTMLElement);
|
||||
});
|
||||
|
||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-visible",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
||||
|
||||
expect(visibleWebview).toBe(webview);
|
||||
expect(webview?.style.position).toBe("");
|
||||
expect(webview?.style.left).toBe("");
|
||||
expect(webview?.style.top).toBe("");
|
||||
expect(webview?.style.zIndex).toBe("");
|
||||
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const visibleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(visibleWebview, {
|
||||
browserId: "browser-visible-pane",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
visibleHost.appendChild(visibleWebview);
|
||||
document.body.appendChild(visibleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-visible-pane",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
expect(webview).toBe(visibleWebview);
|
||||
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-closed",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
removeResidentBrowserWebview("browser-closed");
|
||||
|
||||
expect(webview?.isConnected).toBe(false);
|
||||
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
|
||||
});
|
||||
});
|
||||
226
packages/app/src/components/browser-webview-resident.ts
Normal file
226
packages/app/src/components/browser-webview-resident.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
|
||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readDocument(): Document | null {
|
||||
return typeof document === "undefined" ? null : document;
|
||||
}
|
||||
|
||||
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
||||
// Parked browser webviews must remain paintable at all times; screenshot
|
||||
// correctness depends on the proven states in docs/browser-capture-harness.md.
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "0";
|
||||
host.style.top = "0";
|
||||
host.style.width = "1px";
|
||||
host.style.height = "1px";
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "1";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.display = "block";
|
||||
host.style.zIndex = "";
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "visible";
|
||||
host.style.transform = "";
|
||||
}
|
||||
|
||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||
if (existing) {
|
||||
applyResidentHostParkingStyle(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const host = ownerDocument.createElement("div");
|
||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
||||
applyResidentHostParkingStyle(host);
|
||||
ownerDocument.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
|
||||
if (!browserId) {
|
||||
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
|
||||
}
|
||||
return (
|
||||
residentWebviewSizesByBrowserId.get(browserId) ?? {
|
||||
width: RESIDENT_VIEWPORT_WIDTH,
|
||||
height: RESIDENT_VIEWPORT_HEIGHT,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
|
||||
const dimensions = dimensionsForBrowser(browserId);
|
||||
webview.style.display = "inline-flex";
|
||||
webview.style.flex = "0 0 auto";
|
||||
webview.style.width = `${dimensions.width}px`;
|
||||
webview.style.height = `${dimensions.height}px`;
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
webview.style.position = "absolute";
|
||||
webview.style.left = "0";
|
||||
webview.style.top = "0";
|
||||
webview.style.marginTop = "0";
|
||||
webview.style.zIndex = "0";
|
||||
}
|
||||
|
||||
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
||||
webview.style.position = "";
|
||||
webview.style.left = "";
|
||||
webview.style.top = "";
|
||||
webview.style.marginTop = "";
|
||||
webview.style.zIndex = "";
|
||||
}
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
input: { browserId: string; initialUrl?: string | null },
|
||||
): void {
|
||||
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
|
||||
webview.setAttribute("allowpopups", "true");
|
||||
webview.setAttribute("spellcheck", "false");
|
||||
webview.setAttribute("autosize", "on");
|
||||
if (input.initialUrl) {
|
||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
url: string;
|
||||
}): HTMLElement | null {
|
||||
const browserId = trimNonEmpty(input.browserId);
|
||||
if (!browserId) {
|
||||
return null;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||
if (resident?.isConnected) {
|
||||
releaseResidentBrowserWebview(browserId, resident);
|
||||
return resident;
|
||||
}
|
||||
|
||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||
if (existing) {
|
||||
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
|
||||
releaseResidentBrowserWebview(browserId, existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
|
||||
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
if (!webview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
webview.remove();
|
||||
return;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
||||
}
|
||||
|
||||
export function resizeResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { width: number; height: number } | null {
|
||||
const normalizedBrowserId = trimNonEmpty(input.browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
const width = Math.max(1, Math.round(input.width));
|
||||
const height = Math.max(1, Math.round(input.height));
|
||||
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
|
||||
|
||||
const ownerDocument = readDocument();
|
||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||
if (webview) {
|
||||
webview.style.width = `${width}px`;
|
||||
webview.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function removeResidentBrowserWebview(browserId: string): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
}
|
||||
|
||||
export function clearResidentBrowserWebviewsForTests(): void {
|
||||
for (const webview of residentWebviewsByBrowserId.values()) {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
residentWebviewSizesByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
@@ -4,24 +4,39 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
type ComboboxOption,
|
||||
type ComboboxProps,
|
||||
} from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
getAllProviderModelRows,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
|
||||
function noop() {}
|
||||
@@ -47,22 +62,67 @@ function drillDownRowStyle({
|
||||
pressed && styles.drillDownRowPressed,
|
||||
];
|
||||
}
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
getAllProviderModelRows,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
||||
|
||||
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedSearch = withUnistyles(Search);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedStar = withUnistyles(Star);
|
||||
|
||||
const foregroundMutedMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
|
||||
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const favoriteStarMapping =
|
||||
(isFavorite: boolean, hovered: boolean) =>
|
||||
(theme: Theme): { color: string; fill: string } => {
|
||||
const favoriteColor = theme.colors.palette.amber[500];
|
||||
if (isFavorite) {
|
||||
return { color: favoriteColor, fill: favoriteColor };
|
||||
}
|
||||
return {
|
||||
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
|
||||
fill: "transparent",
|
||||
};
|
||||
};
|
||||
|
||||
type ProviderGlyphTone = "muted" | "foreground";
|
||||
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
size,
|
||||
tone = "muted",
|
||||
}: {
|
||||
provider: string;
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
const Icon = getProviderIcon(provider);
|
||||
const color =
|
||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
||||
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
|
||||
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
|
||||
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
|
||||
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
@@ -80,6 +140,8 @@ interface CombinedModelSelectorProps {
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -87,6 +149,17 @@ interface CombinedModelSelectorProps {
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
||||
desktopMinWidth?: number;
|
||||
/**
|
||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
||||
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
||||
* stops painting its own hover/pressed background and rounded corners. The
|
||||
* trigger itself owns the field visuals and reads hovered/pressed to show its
|
||||
* active state. Without this the trigger stays a content-width toolbar chip
|
||||
* (the composer's layout).
|
||||
*/
|
||||
triggerFill?: boolean;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
@@ -138,9 +211,7 @@ function ModelRow({
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -151,8 +222,8 @@ function ModelRow({
|
||||
);
|
||||
|
||||
const leadingSlot = useMemo(
|
||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
||||
[row.provider],
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
@@ -167,32 +238,10 @@ function ModelRow({
|
||||
}
|
||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
let starColor: string;
|
||||
if (isFavorite) starColor = theme.colors.palette.amber[500];
|
||||
else if (hovered) starColor = theme.colors.foregroundMuted;
|
||||
else starColor = theme.colors.border;
|
||||
return (
|
||||
<Star
|
||||
size={16}
|
||||
color={starColor}
|
||||
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
|
||||
</Pressable>
|
||||
) : null,
|
||||
[
|
||||
onToggleFavorite,
|
||||
handleToggleFavorite,
|
||||
isFavorite,
|
||||
row.provider,
|
||||
row.modelId,
|
||||
theme.colors.palette.amber,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.border,
|
||||
t,
|
||||
],
|
||||
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -294,9 +343,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
}
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -316,18 +363,16 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<View style={styles.rowSpinner}>
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</View>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -335,11 +380,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
|
||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
{stateNode}
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -435,14 +480,13 @@ function ProviderErrorEmptyState({
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => {
|
||||
onRetryProvider?.(providerId);
|
||||
}, [onRetryProvider, providerId]);
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
@@ -466,7 +510,6 @@ function SelectorContent({
|
||||
onRetryProvider,
|
||||
isRetryingProvider,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
const selectedViewProvider = useMemo(
|
||||
@@ -490,7 +533,7 @@ function SelectorContent({
|
||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -503,11 +546,9 @@ function SelectorContent({
|
||||
if (drillSelection.kind === "loading") {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<View style={styles.rowSpinner}>
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</View>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -574,8 +615,10 @@ export function CombinedModelSelector({
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
desktopPlacement,
|
||||
desktopMinWidth,
|
||||
triggerFill = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -631,7 +674,6 @@ export function CombinedModelSelector({
|
||||
);
|
||||
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
return resolveSelectedModelLabel({
|
||||
@@ -693,14 +735,26 @@ export function CombinedModelSelector({
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
],
|
||||
[disabled, isOpen, renderTrigger],
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
||||
// hover/pressed state from the args, so the wrapper must not double-paint.
|
||||
if (triggerFill) {
|
||||
return [
|
||||
styles.trigger,
|
||||
styles.customTriggerWrapper,
|
||||
styles.triggerFill,
|
||||
disabled && styles.triggerDisabled,
|
||||
];
|
||||
}
|
||||
return [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
];
|
||||
},
|
||||
[disabled, isOpen, renderTrigger, triggerFill],
|
||||
);
|
||||
|
||||
const handleBackToAll = useCallback(() => {
|
||||
@@ -726,7 +780,6 @@ export function CombinedModelSelector({
|
||||
if (view.kind === "all") {
|
||||
return { title: t("modelSelector.title") };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openProviderSettings}
|
||||
@@ -739,17 +792,12 @@ export function CombinedModelSelector({
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<Settings
|
||||
size={theme.iconSize.sm}
|
||||
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
</Pressable>
|
||||
);
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: ProviderIconForView ? (
|
||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : undefined,
|
||||
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
actions: headerActions,
|
||||
search: {
|
||||
@@ -765,15 +813,10 @@ export function CombinedModelSelector({
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchResetKey,
|
||||
t,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -789,12 +832,16 @@ export function CombinedModelSelector({
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
})}
|
||||
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
hovered: Boolean(hovered),
|
||||
pressed,
|
||||
})
|
||||
}
|
||||
</Pressable>
|
||||
) : (
|
||||
<ComboboxTrigger
|
||||
@@ -807,8 +854,8 @@ export function CombinedModelSelector({
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
{hasSelectedProvider ? (
|
||||
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
@@ -822,8 +869,8 @@ export function CombinedModelSelector({
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
desktopPlacement={desktopPlacement}
|
||||
desktopMinWidth={desktopMinWidth}
|
||||
desktopFixedHeight={desktopFixedHeight}
|
||||
header={sheetHeader}
|
||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||
@@ -844,7 +891,7 @@ export function CombinedModelSelector({
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -886,6 +933,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
// Stretch the wrapper (and, via column + stretch, its single child) to the
|
||||
// full width of the field, with no background or rounding of its own.
|
||||
triggerFill: {
|
||||
alignSelf: "stretch",
|
||||
flexShrink: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: 0,
|
||||
},
|
||||
favoritesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
@@ -1006,4 +1063,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
providerIconMuted: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
providerIconForeground: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
const explorerOpenGesture = useOpenFileExplorerGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage();
|
||||
void refreshProviderUsage().catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -30,9 +30,7 @@ export function DiffScroll({
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
const animation = useExplorerSidebarAnimationOptional();
|
||||
const closeGestureRef = animation?.closeGestureRef;
|
||||
const closeGestureRef = useFileExplorerCloseGestureRef();
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,16 +29,16 @@ import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
@@ -91,25 +91,11 @@ export function CompactExplorerSidebar({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: true,
|
||||
});
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
@@ -122,184 +108,40 @@ export function CompactExplorerSidebar({
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
mobilePanelGestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
// Use manual activation so child views keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX;
|
||||
closeTouchStartY.value = touch.absoluteY;
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate only on intentional rightward movement.
|
||||
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Right sidebar: swipe right to close (positive translationX)
|
||||
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
const progress = 1 - newTranslateX / windowWidth;
|
||||
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
windowWidth,
|
||||
});
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleCloseFromGesture,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
const backdropCombinedStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.backdrop,
|
||||
backdropAnimatedStyle,
|
||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||
// tap-eating backdrop.
|
||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||
],
|
||||
[backdropAnimatedStyle, isOpen],
|
||||
);
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{
|
||||
width: windowWidth,
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[
|
||||
windowWidth,
|
||||
insets.top,
|
||||
theme.colors.surfaceSidebar,
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
|
||||
);
|
||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||
],
|
||||
[overlayVisible],
|
||||
);
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||
if (!isWeb) overlayPointerEvents = "box-none";
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -604,17 +446,6 @@ function PrTabContent({
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
@@ -55,8 +55,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "size" },
|
||||
];
|
||||
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -129,7 +127,7 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.entryRow,
|
||||
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
|
||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||
],
|
||||
[depth, isSelected, theme.spacing],
|
||||
@@ -143,11 +141,6 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, isExpanded && styles.chevronExpanded],
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
const copyLeading = useMemo(
|
||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
@@ -159,7 +152,7 @@ function TreeRowItem({
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
{depth > 0 && Array.from({ length: depth }, (_, i) => <IndentGuide key={i} index={i} />)}
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
@@ -167,11 +160,7 @@ function TreeRowItem({
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return (
|
||||
<View style={chevronStyle}>
|
||||
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
})()}
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
@@ -1149,13 +1138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
indentGuide: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
entryInfo: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
@@ -1163,16 +1145,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
chevron: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
entryIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
@@ -1302,16 +1274,3 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
||||
|
||||
interface IndentGuideProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
function IndentGuide({ index }: IndentGuideProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const guideStyle = useMemo(
|
||||
() => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
|
||||
[index, theme.spacing],
|
||||
);
|
||||
return <View style={guideStyle} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
|
||||
@@ -414,7 +414,7 @@ export function FilePane({
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||
// gate itself lives in isFileQueryEnabled.
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppVisible();
|
||||
|
||||
const query = useQuery({
|
||||
|
||||
118
packages/app/src/components/hosts/host-filter.tsx
Normal file
118
packages/app/src/components/hosts/host-filter.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, Server } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
HostStatusDotSlot,
|
||||
} from "@/components/hosts/host-picker";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export interface HostFilterProps {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
triggerTestID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "All hosts / <host>" filter pill shared by the History and Schedules
|
||||
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
|
||||
* when only one host exists. Copies the History layout exactly.
|
||||
*/
|
||||
export function HostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
triggerTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID={triggerTestID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<ThemedServer size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
||||
|
||||
export function getHostPickerLabel(
|
||||
hosts: Array<{ label: string; serverId: string }>,
|
||||
|
||||
@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
} from "./host-picker-constants";
|
||||
|
||||
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||
export {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
};
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
@@ -115,6 +121,12 @@ export function HostPickerOption({
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
|
||||
add: "Add host",
|
||||
all: "All hosts",
|
||||
enableBuiltInDaemon: "Enable built-in daemon",
|
||||
};
|
||||
|
||||
function SystemHostPickerOption({
|
||||
active,
|
||||
selected,
|
||||
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
|
||||
active: boolean;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
kind: "add" | "all";
|
||||
kind: "add" | "all" | "enableBuiltInDaemon";
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = kind === "add" ? Plus : Server;
|
||||
const label = kind === "add" ? "Add host" : "All hosts";
|
||||
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
|
||||
const leadingSlot = useMemo(
|
||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
@@ -158,11 +170,13 @@ export interface HostPickerProps {
|
||||
includeAllHost?: boolean;
|
||||
includeAddHost?: boolean;
|
||||
onAddHost?: () => void;
|
||||
includeEnableBuiltInDaemon?: boolean;
|
||||
onEnableBuiltInDaemon?: () => void;
|
||||
showActiveConnection?: boolean;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
||||
desktopMinWidth?: number;
|
||||
addHostTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
@@ -179,11 +193,13 @@ export function HostPicker({
|
||||
includeAllHost,
|
||||
includeAddHost,
|
||||
onAddHost,
|
||||
includeEnableBuiltInDaemon,
|
||||
onEnableBuiltInDaemon,
|
||||
showActiveConnection,
|
||||
onOpenHostSettings,
|
||||
searchable,
|
||||
title,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPlacement = "bottom-start",
|
||||
desktopMinWidth,
|
||||
addHostTestID,
|
||||
hostOptionTestID,
|
||||
@@ -199,8 +215,13 @@ export function HostPicker({
|
||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||
if (includeEnableBuiltInDaemon)
|
||||
hostOptions.push({
|
||||
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
label: "Enable built-in daemon",
|
||||
});
|
||||
return hostOptions;
|
||||
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
||||
|
||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||
|
||||
@@ -208,12 +229,14 @@ export function HostPicker({
|
||||
(id: string) => {
|
||||
if (id === ADD_HOST_OPTION_ID) {
|
||||
onAddHost?.();
|
||||
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||
onEnableBuiltInDaemon?.();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onAddHost, onOpenChange, onSelect],
|
||||
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
||||
);
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
@@ -246,6 +269,11 @@ export function HostPicker({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||
return (
|
||||
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
History,
|
||||
Home,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -11,13 +21,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -28,19 +32,18 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -52,10 +55,12 @@ import {
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
@@ -68,25 +73,21 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
statusGroups: StatusGroup[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
handleRefresh: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
@@ -100,33 +101,31 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
newWorkspace: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
schedules: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
isOpen: boolean;
|
||||
handleViewMore: () => void;
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
selectedAgentId: _selectedAgentId,
|
||||
}: LeftSidebarProps) {
|
||||
void _selectedAgentId;
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -137,22 +136,19 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarWorkspacesList({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
groupMode,
|
||||
shortcutModel,
|
||||
} = useSidebarModel();
|
||||
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -221,16 +217,21 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
router.push(buildSessionsRoute());
|
||||
}, []);
|
||||
|
||||
const handleViewSchedulesNavigate = useCallback(() => {
|
||||
router.push(buildSchedulesRoute());
|
||||
}, []);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
schedules: t("sidebar.sections.schedules"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
@@ -238,8 +239,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -255,34 +257,39 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
/>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
/>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -294,47 +301,58 @@ function FooterIconButton({
|
||||
buttonRef,
|
||||
onPress,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
label,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
iconSize?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
label: string;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
@@ -364,6 +382,7 @@ function SidebarHostPicker({
|
||||
showActiveConnection
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
searchable
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={240}
|
||||
addHostTestID="sidebar-host-add"
|
||||
hostOptionTestID={sidebarHostOptionTestID}
|
||||
@@ -372,7 +391,7 @@ function SidebarHostPicker({
|
||||
buttonRef={triggerRef}
|
||||
onPress={handleOpen}
|
||||
testID="sidebar-hosts-trigger"
|
||||
accessibilityLabel="Hosts"
|
||||
label={label}
|
||||
icon={Server}
|
||||
iconSize={theme.iconSize.sm}
|
||||
theme={theme}
|
||||
@@ -381,22 +400,7 @@ function SidebarHostPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function AddProjectTooltipContent({
|
||||
newAgentKeys,
|
||||
label,
|
||||
}: {
|
||||
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconTooltipContent({
|
||||
function IconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
}: {
|
||||
@@ -481,50 +485,47 @@ function SidebarFooter({
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
accessibilityLabel={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
theme={theme}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
accessibilityLabel={labels.home}
|
||||
label={labels.home}
|
||||
icon={Home}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
accessibilityLabel={labels.settings}
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
@@ -534,8 +535,9 @@ function SidebarFooter({
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -554,260 +556,128 @@ function MobileSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeSidebar();
|
||||
}, [closeSidebar, gestureAnimatingRef]);
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
}, [closeSidebar, handleViewMoreNavigate]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX;
|
||||
closeTouchStartY.value = touch.absoluteY;
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
animateToClose,
|
||||
animateToOpen,
|
||||
handleCloseFromGesture,
|
||||
],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||
[windowWidth, insetsTop, insetsBottom],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||
if (!isWeb) overlayPointerEvents = "box-none";
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
const backdropStyle = useMemo(
|
||||
() => [
|
||||
staticStyles.backdrop,
|
||||
backdropAnimatedStyle,
|
||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||
// tap-eating backdrop.
|
||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||
],
|
||||
[backdropAnimatedStyle, isOpen],
|
||||
);
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
staticStyles.mobileSidebar,
|
||||
mobileSidebarInsetStyle,
|
||||
sidebarAnimatedStyle,
|
||||
{ backgroundColor: theme.colors.surfaceSidebar },
|
||||
],
|
||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||
);
|
||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||
],
|
||||
[overlayVisible],
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insetsBottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
}),
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarInsetStyle}
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
</View>
|
||||
</MobilePanelOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -827,9 +697,11 @@ function DesktopSidebar({
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
handleViewSchedules,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -909,6 +781,14 @@ function DesktopSidebar({
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
@@ -921,8 +801,9 @@ function DesktopSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -988,7 +869,7 @@ function WorkspacesSectionHeader() {
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
@@ -998,7 +879,7 @@ function WorkspacesSectionHeader() {
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Display preferences" />
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
@@ -1010,17 +891,6 @@ function WorkspacesSectionHeader() {
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user