mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da | ||
|
|
b3eb77981a | ||
|
|
3e28b9b59e | ||
|
|
42d1a2e4e3 | ||
|
|
e98d8c7a36 | ||
|
|
9c220c2451 | ||
|
|
b28f291493 | ||
|
|
f226222638 | ||
|
|
6ce4e3f507 | ||
|
|
8d3a43e05d | ||
|
|
7cb0ccba60 | ||
|
|
f1a8e9a563 | ||
|
|
75b11ac93e | ||
|
|
8680bc2236 | ||
|
|
e67e25628b | ||
|
|
860fe92fdd | ||
|
|
ab62a023fd | ||
|
|
cc21262130 | ||
|
|
2a226b3872 | ||
|
|
a13fa4e614 | ||
|
|
ccd2bb2bf3 | ||
|
|
570e652496 | ||
|
|
6d55565d19 | ||
|
|
fec723c775 | ||
|
|
1bf50349a5 | ||
|
|
cda1ba43cc | ||
|
|
f22865df62 | ||
|
|
33ffeb4d42 | ||
|
|
6781521c8e | ||
|
|
4ca89f21f9 | ||
|
|
9430a897e8 | ||
|
|
b0088303fd | ||
|
|
2ba3ad53ab | ||
|
|
b2714ccd89 | ||
|
|
82993c2197 | ||
|
|
742d47fe71 | ||
|
|
441b935b73 | ||
|
|
b2ad903005 | ||
|
|
554e525186 | ||
|
|
71ce96b60f | ||
|
|
d9615c1e98 | ||
|
|
6fea8305a7 | ||
|
|
172de4fbc2 | ||
|
|
adc0c01782 | ||
|
|
3eed8c31a2 | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 |
@@ -1,11 +1,31 @@
|
|||||||
.git
|
.git
|
||||||
|
.debug.conversations
|
||||||
|
.debug
|
||||||
.dev
|
.dev
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
**/.playwright-mcp
|
||||||
.paseo
|
.paseo
|
||||||
|
**/.paseo-provider-history
|
||||||
|
.plans
|
||||||
.tasks
|
.tasks
|
||||||
|
.valknut
|
||||||
|
.claude/settings.local.json
|
||||||
|
**/.claude/settings.local.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
.claude/worktrees
|
||||||
.wrangler
|
.wrangler
|
||||||
**/.wrangler
|
**/.wrangler
|
||||||
**/.tanstack
|
**/.tanstack
|
||||||
|
PLAN.md
|
||||||
|
valknut-report.html
|
||||||
|
valknut-report.json
|
||||||
|
.env*
|
||||||
|
**/.env*
|
||||||
|
.dev.vars
|
||||||
|
**/.dev.vars
|
||||||
|
*.pem
|
||||||
|
**/*.pem
|
||||||
|
**/.secrets
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/build
|
**/build
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -113,7 +112,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||||
|
|
||||||
@@ -170,7 +168,6 @@ jobs:
|
|||||||
}
|
}
|
||||||
Start-Sleep -Seconds (20 * $attempt)
|
Start-Sleep -Seconds (20 * $attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -202,7 +199,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
@@ -227,7 +223,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build client dependencies
|
- name: Build client dependencies
|
||||||
run: npm run build:client
|
run: npm run build:client
|
||||||
|
|
||||||
@@ -270,7 +265,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build app dependencies
|
- name: Build app dependencies
|
||||||
run: npm run build:app-deps
|
run: npm run build:app-deps
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,22 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "docker/**"
|
- "docker/**"
|
||||||
|
- ".dockerignore"
|
||||||
- ".github/workflows/docker.yml"
|
- ".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:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags:
|
tags:
|
||||||
@@ -13,7 +28,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
paseo_version:
|
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
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
publish:
|
publish:
|
||||||
@@ -24,15 +39,6 @@ on:
|
|||||||
options:
|
options:
|
||||||
- "false"
|
- "false"
|
||||||
- "true"
|
- "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:
|
publish_latest:
|
||||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||||
required: false
|
required: false
|
||||||
@@ -57,7 +63,6 @@ jobs:
|
|||||||
image: ${{ steps.values.outputs.image }}
|
image: ${{ steps.values.outputs.image }}
|
||||||
install_version: ${{ steps.values.outputs.install_version }}
|
install_version: ${{ steps.values.outputs.install_version }}
|
||||||
publish: ${{ steps.values.outputs.publish }}
|
publish: ${{ steps.values.outputs.publish }}
|
||||||
source_build: ${{ steps.values.outputs.source_build }}
|
|
||||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||||
steps:
|
steps:
|
||||||
@@ -68,7 +73,6 @@ jobs:
|
|||||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -76,9 +80,9 @@ jobs:
|
|||||||
|
|
||||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
image="ghcr.io/${owner}/paseo"
|
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
|
publish=false
|
||||||
source_build=false
|
|
||||||
publish_latest=false
|
publish_latest=false
|
||||||
prerelease=false
|
prerelease=false
|
||||||
|
|
||||||
@@ -87,7 +91,6 @@ jobs:
|
|||||||
publish=true
|
publish=true
|
||||||
if [[ "${REF_NAME}" == *-* ]]; then
|
if [[ "${REF_NAME}" == *-* ]]; then
|
||||||
prerelease=true
|
prerelease=true
|
||||||
source_build=true
|
|
||||||
else
|
else
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -104,24 +107,6 @@ jobs:
|
|||||||
prerelease=true
|
prerelease=true
|
||||||
fi
|
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
|
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -137,14 +122,13 @@ jobs:
|
|||||||
echo "image=${image}"
|
echo "image=${image}"
|
||||||
echo "install_version=${install_version}"
|
echo "install_version=${install_version}"
|
||||||
echo "publish=${publish}"
|
echo "publish=${publish}"
|
||||||
echo "source_build=${source_build}"
|
|
||||||
echo "check_tag=${check_tag}"
|
echo "check_tag=${check_tag}"
|
||||||
echo "publish_tags<<EOF"
|
echo "publish_tags<<EOF"
|
||||||
echo "${publish_tags}"
|
echo "${publish_tags}"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$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:
|
build:
|
||||||
needs: setup
|
needs: setup
|
||||||
@@ -160,8 +144,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
context: .
|
||||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
file: docker/base/Dockerfile
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
@@ -193,8 +177,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
context: .
|
||||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
file: docker/base/Dockerfile
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ valknut-report.json/
|
|||||||
.plans/
|
.plans/
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
|
packages/protocol/src/generated/validation/*.aot.ts
|
||||||
|
|
||||||
/artifacts
|
/artifacts
|
||||||
packages/desktop/.cache/
|
packages/desktop/.cache/
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.104-beta.4 - 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
|
||||||
|
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
|
||||||
|
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
|
||||||
|
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
||||||
|
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
||||||
|
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
||||||
|
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||||
|
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||||
|
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||||
|
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
||||||
|
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
||||||
|
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
||||||
|
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
|
||||||
|
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
|
||||||
|
|
||||||
## 0.1.103 - 2026-07-01
|
## 0.1.103 - 2026-07-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,34 +21,36 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
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 |
|
| Doc | What's in it |
|
||||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
| [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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
| [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
|
## Quick start
|
||||||
|
|
||||||
@@ -91,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
- `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:
|
- **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.
|
- **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).
|
- 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).
|
- 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?"
|
- 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.
|
- **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 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.
|
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||||
|
|||||||
+30
-6
@@ -1,9 +1,32 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
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 \
|
ENV HOME=/home/paseo \
|
||||||
PASEO_HOME=/home/paseo/.paseo \
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
@@ -29,13 +52,14 @@ RUN set -eux; \
|
|||||||
gosu \
|
gosu \
|
||||||
lbzip2 \
|
lbzip2 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
|
procps \
|
||||||
tini; \
|
tini; \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
npm install -g --omit=optional \
|
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||||
"@getpaseo/server@${PASEO_VERSION}" \
|
rm -rf /tmp/paseo-packs; \
|
||||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
|
||||||
npm cache clean --force; \
|
npm cache clean --force; \
|
||||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
test -f "$server_entry"; \
|
test -f "$server_entry"; \
|
||||||
@@ -66,7 +90,7 @@ RUN set -eux; \
|
|||||||
"$XDG_CACHE_HOME"; \
|
"$XDG_CACHE_HOME"; \
|
||||||
chown -R paseo:paseo /home/paseo /workspace
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
COPY rootfs/ /
|
COPY docker/base/rootfs/ /
|
||||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
WORKDIR /workspace
|
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"]
|
|
||||||
@@ -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.
|
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||||
>
|
>
|
||||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
> **In-app browser 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
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Browser Capture Harness
|
||||||
|
|
||||||
|
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
||||||
|
It validates the compositor behavior that unit tests cannot see:
|
||||||
|
|
||||||
|
- the resident automation `<webview>` starts in the production parking state;
|
||||||
|
- the parked guest remains paintable and has a copyable viewport frame;
|
||||||
|
- the resident webview guest is sized to 1280x800 logical pixels;
|
||||||
|
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||||
|
stacking changes;
|
||||||
|
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||||
|
by retrying until the frame appears;
|
||||||
|
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||||
|
the permanent production parking state;
|
||||||
|
- guest background throttling can be disabled once at attach without per-capture
|
||||||
|
renderer coordination.
|
||||||
|
|
||||||
|
Run it with the repo Electron:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run capture-harness --workspace=@getpaseo/desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the browser automation fixture with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation group uses a real guest webview to verify the page-side ref contract:
|
||||||
|
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
||||||
|
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||||
|
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
||||||
|
page-context evaluation, including passing a resolved ref element as the function argument.
|
||||||
|
|
||||||
|
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||||
|
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||||
|
focus; a normal Electron app launch can still activate the app and steal focus.
|
||||||
|
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
||||||
|
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
||||||
|
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
||||||
|
the compositor only needs visible inactive windows, and harness runs must not steal
|
||||||
|
focus from the person using the machine.
|
||||||
|
|
||||||
|
The harness writes PNG evidence and `results.json` to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/desktop/capture-harness/out/
|
||||||
|
```
|
||||||
|
|
||||||
|
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||||
|
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||||
|
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||||
|
is usually saved as 2560x1600.
|
||||||
|
|
||||||
|
## Mechanism
|
||||||
|
|
||||||
|
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||||
|
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||||
|
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
||||||
|
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
||||||
|
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
||||||
|
overlap at `left:0`, `top:0`.
|
||||||
|
|
||||||
|
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||||
|
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||||
|
invalidates before each attempt, and retries known first-frame failures within the
|
||||||
|
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||||
|
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||||
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Runtime PID Lock
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/paseo.pid`
|
||||||
|
|
||||||
|
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||||
|
It is a runtime lock, not a durable settings file.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||||
|
| `hostname` | `string` | Host that created the lock |
|
||||||
|
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||||
|
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||||
|
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||||
|
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||||
|
|
||||||
|
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||||
|
executable must be the desktop app executable or live inside the same desktop
|
||||||
|
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||||
|
it because an npm-installed CLI has no authoritative desktop install root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Agent Record
|
## 1. Agent Record
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
|
|||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
of commands. Both run sequentially.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
|
|||||||
+9
-10
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
|||||||
|
|
||||||
The official image:
|
The official image:
|
||||||
|
|
||||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||||
or from source-built workspace tarballs for beta images
|
|
||||||
- runs the daemon as the non-root `paseo` user
|
- runs the daemon as the non-root `paseo` user
|
||||||
- listens on `0.0.0.0:6767` inside the container
|
- listens on `0.0.0.0:6767` inside the container
|
||||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
- 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
|
## Building Locally
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg PASEO_VERSION=0.1.102 \
|
--build-arg PASEO_VERSION=0.1.102 \
|
||||||
-t paseo: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
|
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 \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=0.1.102-beta.1 \
|
-f paseo_version=0.1.102-beta.1 \
|
||||||
-f publish=true \
|
-f publish=true
|
||||||
-f source_build=auto
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
||||||
versions build from the checked-out source tree by default and publish only the
|
from the checked-out source tree and publishes only the exact prerelease image
|
||||||
exact prerelease image tag.
|
tag for prerelease versions.
|
||||||
|
|
||||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ Anyone who builds software:
|
|||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
- 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
|
- 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
|
- 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
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
- Frequent releases (multiple per week)
|
- Frequent releases (multiple per week)
|
||||||
- Community contributions across packaging, providers, and bug fixes
|
- Community contributions across packaging, providers, and bug fixes
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Protocol Validation
|
||||||
|
|
||||||
|
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
||||||
|
|
||||||
|
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
||||||
|
|
||||||
|
## Runtime Path
|
||||||
|
|
||||||
|
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||||
|
|
||||||
|
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||||
|
|
||||||
|
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
||||||
|
|
||||||
|
## Codegen Ownership
|
||||||
|
|
||||||
|
The protocol package owns generation.
|
||||||
|
|
||||||
|
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||||
|
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||||
|
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||||
|
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||||
|
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||||
|
|
||||||
|
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||||
|
|
||||||
|
## Regression Tests
|
||||||
|
|
||||||
|
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
||||||
|
|
||||||
|
- discriminated-union branch output must propagate `.default()` fields
|
||||||
|
- current sequential item routing must accept `tool_call`-like status branches
|
||||||
|
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
||||||
|
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
||||||
|
|
||||||
|
## Schema Purity
|
||||||
|
|
||||||
|
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
||||||
|
|
||||||
|
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
||||||
|
|
||||||
|
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
||||||
@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
|||||||
|
|
||||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
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`.
|
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.
|
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.
|
||||||
|
|||||||
+2
-3
@@ -50,7 +50,7 @@ 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.
|
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`.
|
||||||
|
|
||||||
**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).
|
**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 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=X.Y.Z-beta.N \
|
-f paseo_version=X.Y.Z-beta.N \
|
||||||
-f publish=true \
|
-f publish=true
|
||||||
-f source_build=auto
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||||
|
|||||||
Generated
+49
-28
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -21797,9 +21797,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.6",
|
"version": "4.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -28563,9 +28563,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -35069,6 +35069,26 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.25.1",
|
"version": "3.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
@@ -35132,7 +35152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36150,12 +36170,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.103",
|
"@getpaseo/client": "0.1.104-beta.4",
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/server": "0.1.103",
|
"@getpaseo/server": "0.1.104-beta.4",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36401,10 +36421,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/relay": "0.1.103",
|
"@getpaseo/relay": "0.1.104-beta.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36415,7 +36435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36658,7 +36678,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37554,7 +37574,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37786,19 +37806,20 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6",
|
||||||
|
"zod-aot": "0.20.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38016,15 +38037,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.103",
|
"@getpaseo/client": "0.1.104-beta.4",
|
||||||
"@getpaseo/highlight": "0.1.103",
|
"@getpaseo/highlight": "0.1.104-beta.4",
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/relay": "0.1.103",
|
"@getpaseo/relay": "0.1.104-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38561,7 +38582,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
"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": "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",
|
"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",
|
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
// Model B entry points into the New Workspace screen. The per-project
|
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
// the global button (universal) and each project's per-row New workspace icon
|
||||||
// button (universal) and each git project's own new-worktree icon (preselects
|
// (preselects that project) — shown for git projects and for non-git projects on
|
||||||
// that project). These specs prove the global entry opens the screen, the
|
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||||
// project icon preselects the right project across the reused 'new' screen, and
|
// screen, the project icon preselects the right project across the reused 'new'
|
||||||
// non-git projects never offer the worktree Isolation control.
|
// screen, and non-git projects never offer the worktree Isolation control.
|
||||||
|
|
||||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||||
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
|||||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Open New Workspace for the non-git project via the global button, then
|
// 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);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
|
|||||||
await page.goto(buildSchedulesRoute());
|
await page.goto(buildSchedulesRoute());
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(row).toContainText("ten-second-stream");
|
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||||
|
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
buildFakeScheduleHostWorkspace,
|
buildFakeScheduleHostWorkspace,
|
||||||
installFakeScheduleHost,
|
installFakeScheduleHost,
|
||||||
} from "./helpers/schedule-fake-host";
|
} from "./helpers/schedule-fake-host";
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
@@ -82,9 +81,9 @@ test.describe("Schedules project target", () => {
|
|||||||
await page.getByRole("button", { name: "Schedules" }).click();
|
await page.getByRole("button", { name: "Schedules" }).click();
|
||||||
await expect(page).toHaveURL(/\/schedules$/);
|
await expect(page).toHaveURL(/\/schedules$/);
|
||||||
await expect(page).not.toHaveURL(/\/h\//);
|
await expect(page).not.toHaveURL(/\/h\//);
|
||||||
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
|
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New schedule" }).click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -127,10 +126,8 @@ test.describe("Schedules project target", () => {
|
|||||||
label: "Fake host",
|
label: "Fake host",
|
||||||
port: fakePort,
|
port: fakePort,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
|
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
timeout: 30_000,
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
});
|
|
||||||
await page.getByRole("button", { name: "New schedule" }).click();
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
|||||||
test.describe("Model B sidebar shape", () => {
|
test.describe("Model B sidebar shape", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
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, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(workspaceRow(page, nonGitSecondId)).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
|
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
// git project can branch off a worktree, and the non-git project can add
|
||||||
// none, since worktree creation needs a git checkout.
|
// another workspace because the host supports workspaceMultiplicity.
|
||||||
await projectRow(page, gitProject.projectId).hover();
|
await projectRow(page, gitProject.projectId).hover();
|
||||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||||
timeout: 30_000,
|
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
|
// The global new-workspace button is the universal entry — present for both
|
||||||
// kinds regardless of their per-row affordance.
|
// kinds regardless of their per-row affordance.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
@@ -36,13 +36,6 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
|
|||||||
return "Review";
|
return "Review";
|
||||||
}
|
}
|
||||||
|
|
||||||
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
|
||||||
fix: "workspace.browser.annotate.intents.fix",
|
|
||||||
change: "workspace.browser.annotate.intents.change",
|
|
||||||
question: "workspace.browser.annotate.intents.question",
|
|
||||||
approve: "workspace.browser.annotate.intents.approve",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTextAttachmentSubtitle(
|
function getTextAttachmentSubtitle(
|
||||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -96,11 +89,10 @@ export function getWorkspaceAttachmentPillContent(
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
): AttachmentPillContent {
|
): AttachmentPillContent {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
const intent = attachment.attachment.intent;
|
|
||||||
return {
|
return {
|
||||||
icon: attachmentBrowserIcon,
|
icon: attachmentBrowserIcon,
|
||||||
title: attachment.attachment.tag,
|
title: attachment.attachment.tag,
|
||||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
subtitle: t("composer.attachments.element"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export interface AttachmentMetadata {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind of review feedback the user is attaching to a selected browser
|
|
||||||
* element, sent to the agent alongside the element context.
|
|
||||||
*/
|
|
||||||
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
|
|
||||||
|
|
||||||
export interface BrowserElementAttachment {
|
export interface BrowserElementAttachment {
|
||||||
url: string;
|
url: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
@@ -50,8 +44,6 @@ export interface BrowserElementAttachment {
|
|||||||
children: string[];
|
children: string[];
|
||||||
/** Free-text review note the user wrote about this element, if any. */
|
/** Free-text review note the user wrote about this element, if any. */
|
||||||
comment?: string;
|
comment?: string;
|
||||||
/** What the user wants the agent to do with this element, if annotated. */
|
|
||||||
intent?: BrowserAnnotationIntent;
|
|
||||||
/**
|
/**
|
||||||
* Cropped screenshot of the selected element, sent to the agent as an image
|
* Cropped screenshot of the selected element, sent to the agent as an image
|
||||||
* alongside the textual element context. Persisted via the attachment store;
|
* alongside the textual element context. Persisted via the attachment store;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||||
import { mountBrowserAutomationHandler } from "./handler";
|
import { mountBrowserAutomationHandler } from "./handler";
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
import { useBrowserStore } from "@/stores/browser-store";
|
||||||
@@ -8,20 +9,6 @@ import {
|
|||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: {
|
|
||||||
navigate: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: {
|
|
||||||
getItem: vi.fn(async () => null),
|
|
||||||
setItem: vi.fn(async () => undefined),
|
|
||||||
removeItem: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "browser.automation.execute.request" }
|
{ type: "browser.automation.execute.request" }
|
||||||
@@ -30,9 +17,28 @@ type BrowserAutomationExecuteResponse = Extract<
|
|||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
{ type: "browser.automation.execute.response" }
|
{ 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 {
|
class FakeDaemonClient {
|
||||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||||
|
|
||||||
public on(
|
public on(
|
||||||
@@ -55,6 +61,107 @@ class FakeDaemonClient {
|
|||||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||||
this.handler?.(nextRequest);
|
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 {
|
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||||
@@ -70,45 +177,69 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
|||||||
type: "browser.automation.execute.request",
|
type: "browser.automation.execute.request",
|
||||||
requestId: "req-new",
|
requestId: "req-new",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
command: {
|
||||||
|
command: "new_tab",
|
||||||
|
args: { url: "https://example.com" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
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 {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: [],
|
tabs: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: currentBrowserTabs(),
|
tabs: currentBrowserTabs(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPromises(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForAsyncWork(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentBrowserTabs() {
|
function currentBrowserTabs() {
|
||||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||||
browserId: browser.browserId,
|
browserId: browser.browserId,
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
url: browser.url,
|
url: browser.url,
|
||||||
title: browser.title,
|
title: browser.title,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -116,151 +247,268 @@ function currentBrowserTabs() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", () => {
|
describe("mountBrowserAutomationHandler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
browserAutomationStorage.clear();
|
||||||
useBrowserStore.setState({ browsersById: {} });
|
useBrowserStore.setState({ browsersById: {} });
|
||||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
|
|
||||||
const setAgentActiveBrowser = vi.fn(async () => undefined);
|
|
||||||
const registerWorkspaceBrowser = vi.fn(async () => undefined);
|
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
});
|
});
|
||||||
if (!workspaceKey) throw new Error("expected workspace key");
|
if (!workspaceKey) {
|
||||||
const focusedTabId = useWorkspaceLayoutStore
|
throw new Error("Expected workspace key");
|
||||||
|
}
|
||||||
|
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||||
.getState()
|
.getState()
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||||
if (!focusedTabId) throw new Error("expected focused tab");
|
browser.mount({ serverId: "server-1" });
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () =>
|
|
||||||
({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand,
|
|
||||||
registerWorkspaceBrowser,
|
|
||||||
setWorkspaceActiveBrowser,
|
|
||||||
setAgentActiveBrowser,
|
|
||||||
},
|
|
||||||
}) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
const payload = client.sentResponses[0]?.payload;
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
expect(payload?.ok).toBe(true);
|
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
||||||
if (!payload?.ok) throw new Error("expected success");
|
|
||||||
expect(payload.result.command).toBe("new_tab");
|
|
||||||
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
|
|
||||||
expect(payload.result.url).toBe("https://example.com");
|
|
||||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
|
|
||||||
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
|
|
||||||
);
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout?.root.kind).toBe("pane");
|
expect(openedTabs).toEqual([
|
||||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
expect.objectContaining({
|
||||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
target: { kind: "browser", browserId: result.browserId },
|
||||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
}),
|
||||||
browserId: payload.result.browserId,
|
]);
|
||||||
workspaceId: "/repo",
|
expect(layout?.root).toEqual(
|
||||||
});
|
expect.objectContaining({
|
||||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
kind: "pane",
|
||||||
agentId: "agent-1",
|
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
|
||||||
browserId: payload.result.browserId,
|
}),
|
||||||
});
|
);
|
||||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||||
browserId: payload.result.browserId,
|
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||||
url: "https://example.com",
|
]);
|
||||||
});
|
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
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("returns browser_timeout when the resident webview does not register", async () => {
|
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
browser.browser.response = emptyListTabsPayload();
|
||||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
browser.mount({
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs: 1,
|
registrationWaitTimeoutMs: 1,
|
||||||
registrationPollIntervalMs: 1,
|
registrationPollIntervalMs: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await waitForAsyncWork();
|
await waitForRegistrationTimeout();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
ok: false,
|
type: "browser.automation.execute.response",
|
||||||
error: {
|
payload: {
|
||||||
code: "browser_timeout",
|
requestId: "req-new",
|
||||||
retryable: true,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_timeout",
|
||||||
|
message: expect.stringContaining("Timed out waiting for browser tab"),
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
expect(browser.resident.ensuredWebviews).toEqual([
|
||||||
expect.objectContaining({ url: "https://example.com" }),
|
expect.objectContaining({ url: "https://example.com" }),
|
||||||
);
|
]);
|
||||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => {
|
browser.browser.thrownError = new Error("IPC registration check failed");
|
||||||
throw new Error("IPC registration check failed");
|
browser.mount({ serverId: "server-1" });
|
||||||
});
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
|
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,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: "browser_unknown_error",
|
code: "browser_tab_not_found",
|
||||||
message: "IPC registration check failed",
|
message: `No browser tab found for ID: ${result.browserId}`,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends a success response from the desktop bridge", async () => {
|
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
requestId: "req-1",
|
serverId: "server-1",
|
||||||
ok: true as const,
|
workspaceId: "wks_workspace_a",
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
});
|
||||||
|
if (!workspaceKey) {
|
||||||
|
throw new Error("Expected workspace key");
|
||||||
|
}
|
||||||
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
|
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
browser.receive(browserCloseTabRequest(result.browserId));
|
||||||
expect(client.sentResponses).toEqual([
|
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",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -272,14 +520,14 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing bridge sends browser_unsupported", async () => {
|
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
browser.mount({ host: null });
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses).toEqual([
|
expect(browser.client.sentResponses).toEqual([
|
||||||
{
|
{
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -287,7 +535,7 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not available in this app runtime.",
|
message: "Browser automation is not available in this app runtime.",
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -296,80 +544,68 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("typed bridge errors become failure responses", async () => {
|
test("typed bridge errors become failure responses", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = {
|
||||||
client,
|
code: "browser_tab_not_found",
|
||||||
getHost: () => ({
|
message: "Browser tab browser-1 was not found.",
|
||||||
browser: {
|
retryable: false,
|
||||||
executeAutomationCommand: async () => {
|
};
|
||||||
throw {
|
browser.mount();
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
browser.receive(browserAutomationRequest());
|
||||||
retryable: false,
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
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 () => {
|
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = new Error(
|
||||||
client,
|
'No handler registered for "paseo:browser:execute-automation-command"',
|
||||||
getHost: () => ({
|
);
|
||||||
browser: {
|
browser.mount();
|
||||||
executeAutomationCommand: async () => {
|
|
||||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unsubscribe stops handling requests", async () => {
|
test("unsubscribe stops handling browser automation requests", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
browser.mount();
|
||||||
requestId: "req-1",
|
|
||||||
ok: true as const,
|
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
const unsubscribe = mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
browser.unmount();
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
expect(browser.browser.executedRequests).toEqual([]);
|
||||||
expect(client.sentResponses).toEqual([]);
|
expect(browser.client.sentResponses).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
||||||
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
|
import {
|
||||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
|
||||||
|
removeResidentBrowserWebview,
|
||||||
|
resizeResidentBrowserWebview,
|
||||||
|
} from "@/components/browser-webview-resident";
|
||||||
|
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
|
collectAllTabs,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ export function mountBrowserAutomationHandler(
|
|||||||
options: BrowserAutomationHandlerOptions,
|
options: BrowserAutomationHandlerOptions,
|
||||||
): () => void {
|
): () => void {
|
||||||
const getHost = options.getHost ?? getDesktopHost;
|
const getHost = options.getHost ?? getDesktopHost;
|
||||||
return options.client.on("browser.automation.execute.request", (request) => {
|
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
||||||
void handleBrowserAutomationRequest({
|
void handleBrowserAutomationRequest({
|
||||||
client: options.client,
|
client: options.client,
|
||||||
getHost,
|
getHost,
|
||||||
@@ -56,6 +61,9 @@ export function mountBrowserAutomationHandler(
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mountBrowserAutomationDaemonClientHandler(
|
export function mountBrowserAutomationDaemonClientHandler(
|
||||||
@@ -111,7 +119,32 @@ async function handleBrowserAutomationRequest(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rememberAgentBrowserTarget({ request, browserHost });
|
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) {
|
if (!executeAutomationCommand) {
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
client.sendBrowserAutomationExecuteResponse({
|
||||||
@@ -119,7 +152,7 @@ async function handleBrowserAutomationRequest(params: {
|
|||||||
payload: browserAutomationFailure({
|
payload: browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not available in this app runtime.",
|
message: "Browser automation is not available in this app runtime.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -139,6 +172,127 @@ async function handleBrowserAutomationRequest(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
async function openBrowserTabForRequest(params: {
|
||||||
request: BrowserAutomationExecuteRequest;
|
request: BrowserAutomationExecuteRequest;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -159,11 +313,11 @@ async function openBrowserTabForRequest(params: {
|
|||||||
BrowserAutomationExecuteRequest["command"],
|
BrowserAutomationExecuteRequest["command"],
|
||||||
{ command: "new_tab" }
|
{ command: "new_tab" }
|
||||||
>;
|
>;
|
||||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
const workspaceId = request.workspaceId;
|
||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_no_tab",
|
code: "browser_unsupported",
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
message: "Cannot create a browser tab without a workspace context.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,7 +328,7 @@ async function openBrowserTabForRequest(params: {
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_no_tab",
|
code: "browser_unsupported",
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
message: "Cannot create a browser tab without a workspace context.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,9 +338,6 @@ async function openBrowserTabForRequest(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||||
if (request.agentId) {
|
|
||||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browserHost?.executeAutomationCommand) {
|
if (browserHost?.executeAutomationCommand) {
|
||||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||||
@@ -204,7 +355,7 @@ async function openBrowserTabForRequest(params: {
|
|||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_timeout",
|
code: "browser_timeout",
|
||||||
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
|
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,7 +386,7 @@ async function waitForBrowserRegistration(params: {
|
|||||||
agentId: params.request.agentId,
|
agentId: params.request.agentId,
|
||||||
cwd: params.request.cwd,
|
cwd: params.request.cwd,
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
command: { command: "list_tabs", args: {} },
|
||||||
});
|
});
|
||||||
if (payload.ok && payload.result.command === "list_tabs") {
|
if (payload.ok && payload.result.command === "list_tabs") {
|
||||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||||
@@ -251,28 +402,6 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rememberAgentBrowserTarget(input: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!input.request.agentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const browserId = readRequestBrowserId(input.request);
|
|
||||||
if (!browserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
|
|
||||||
if (request.browserId) {
|
|
||||||
return request.browserId;
|
|
||||||
}
|
|
||||||
const args = request.command.args as { browserId?: unknown };
|
|
||||||
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBridgePayload(
|
function normalizeBridgePayload(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
payload: BrowserAutomationResponsePayload,
|
payload: BrowserAutomationResponsePayload,
|
||||||
@@ -294,14 +423,14 @@ function normalizeThrownBridgeError(
|
|||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId,
|
requestId,
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
message: "Browser automation is not implemented by this app build yet.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId,
|
requestId,
|
||||||
code: "browser_unknown_error",
|
code: "browser_unknown_error",
|
||||||
message: message || "Desktop browser automation failed.",
|
message: message || "Browser automation failed.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
|
||||||
Maximize,
|
Maximize,
|
||||||
Monitor,
|
Monitor,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
PencilRuler,
|
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Tablet,
|
Tablet,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import {
|
import {
|
||||||
@@ -40,11 +41,7 @@ import {
|
|||||||
useWorkspaceAttachments,
|
useWorkspaceAttachments,
|
||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type {
|
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
|
||||||
AttachmentMetadata,
|
|
||||||
BrowserAnnotationIntent,
|
|
||||||
BrowserElementAttachment,
|
|
||||||
} from "@/attachments/types";
|
|
||||||
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +49,6 @@ import {
|
|||||||
isElectronRuntime,
|
isElectronRuntime,
|
||||||
type DesktopBrowserShortcutEvent,
|
type DesktopBrowserShortcutEvent,
|
||||||
} from "@/desktop/host";
|
} from "@/desktop/host";
|
||||||
import { isDev } from "@/constants/platform";
|
|
||||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||||
import {
|
import {
|
||||||
prepareBrowserWebview,
|
prepareBrowserWebview,
|
||||||
@@ -79,25 +75,14 @@ type WebTextInput = TextInput & {
|
|||||||
getNativeRef?: () => unknown;
|
getNativeRef?: () => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserElementSelection = Omit<
|
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
|
||||||
BrowserElementAttachment,
|
|
||||||
"formatted" | "comment" | "intent"
|
|
||||||
> & {
|
|
||||||
attributes?: Record<string, string>;
|
attributes?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BrowserElementAnnotation {
|
interface BrowserElementAnnotation {
|
||||||
comment: string;
|
comment: string;
|
||||||
intent: BrowserAnnotationIntent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
|
|
||||||
"fix",
|
|
||||||
"change",
|
|
||||||
"question",
|
|
||||||
"approve",
|
|
||||||
];
|
|
||||||
|
|
||||||
type DeviceSizeId =
|
type DeviceSizeId =
|
||||||
| "responsive"
|
| "responsive"
|
||||||
| "iphone-se"
|
| "iphone-se"
|
||||||
@@ -223,10 +208,6 @@ function formatElementAttachment(
|
|||||||
const html = truncateText(selection.outerHTML.trim(), 800);
|
const html = truncateText(selection.outerHTML.trim(), 800);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (annotation) {
|
|
||||||
parts.push(`intent: ${annotation.intent}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.reactSource?.fileName) {
|
if (selection.reactSource?.fileName) {
|
||||||
const loc = [
|
const loc = [
|
||||||
selection.reactSource.fileName,
|
selection.reactSource.fileName,
|
||||||
@@ -289,7 +270,6 @@ function buildBrowserElementAttachment(
|
|||||||
parentChain: selection.parentChain,
|
parentChain: selection.parentChain,
|
||||||
children: selection.children,
|
children: selection.children,
|
||||||
...(comment ? { comment } : {}),
|
...(comment ? { comment } : {}),
|
||||||
...(annotation ? { intent: annotation.intent } : {}),
|
|
||||||
...(screenshot ? { screenshot } : {}),
|
...(screenshot ? { screenshot } : {}),
|
||||||
formatted: formatElementAttachment(selection, annotation),
|
formatted: formatElementAttachment(selection, annotation),
|
||||||
};
|
};
|
||||||
@@ -641,10 +621,11 @@ export function BrowserPane({
|
|||||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||||
const domReadyRef = useRef(false);
|
const domReadyRef = useRef(false);
|
||||||
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
||||||
const [selectorActive, setSelectorActive] = useState(false);
|
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
|
||||||
|
const selectorActive = selectorMode !== null;
|
||||||
// Which action the active selector performs on click: open the annotation card
|
// Which action the active selector performs on click: open the annotation card
|
||||||
// ("annotate") or copy the element to the clipboard ("grab").
|
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
|
||||||
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
|
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const toastRef = useRef(toast);
|
const toastRef = useRef(toast);
|
||||||
toastRef.current = toast;
|
toastRef.current = toast;
|
||||||
@@ -1074,7 +1055,7 @@ export function BrowserPane({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const grabElementToClipboard = useCallback(
|
const screenshotElementToClipboard = useCallback(
|
||||||
async (selection: BrowserElementSelection) => {
|
async (selection: BrowserElementSelection) => {
|
||||||
const text = formatElementAttachment(selection);
|
const text = formatElementAttachment(selection);
|
||||||
const copyElement = getDesktopHost()?.browser?.copyElement;
|
const copyElement = getDesktopHost()?.browser?.copyElement;
|
||||||
@@ -1087,19 +1068,23 @@ export function BrowserPane({
|
|||||||
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
||||||
imageDataUrl = dataUrl ?? undefined;
|
imageDataUrl = dataUrl ?? undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[browser-pane] capture element for grab failed", error);
|
console.warn("[browser-pane] capture element for screenshot failed", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copiedMessage = imageDataUrl
|
||||||
|
? t("workspace.browser.controls.screenshotCopied")
|
||||||
|
: t("workspace.browser.controls.elementCopied");
|
||||||
|
|
||||||
// Copy via the main process; the renderer's navigator.clipboard rejects
|
// Copy via the main process; the renderer's navigator.clipboard rejects
|
||||||
// with NotAllowedError because focus is inside the guest <webview>.
|
// with NotAllowedError because focus is inside the guest <webview>.
|
||||||
if (typeof copyElement === "function") {
|
if (typeof copyElement === "function") {
|
||||||
try {
|
try {
|
||||||
const ok = await copyElement({ text, imageDataUrl });
|
const ok = await copyElement({ text, imageDataUrl });
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
toastRef.current?.show(copiedMessage, { variant: "success" });
|
||||||
} else {
|
} else {
|
||||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1110,10 +1095,12 @@ export function BrowserPane({
|
|||||||
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
||||||
try {
|
try {
|
||||||
await Clipboard.setStringAsync(text);
|
await Clipboard.setStringAsync(text);
|
||||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[browser-pane] clipboard fallback failed", error);
|
console.warn("[browser-pane] clipboard fallback failed", error);
|
||||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
@@ -1121,8 +1108,8 @@ export function BrowserPane({
|
|||||||
|
|
||||||
const handleSelectorResult = useCallback(
|
const handleSelectorResult = useCallback(
|
||||||
(selection: BrowserElementSelection) => {
|
(selection: BrowserElementSelection) => {
|
||||||
if (selectorModeRef.current === "grab") {
|
if (selectorModeRef.current === "screenshot") {
|
||||||
void grabElementToClipboard(selection);
|
void screenshotElementToClipboard(selection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingScreenshotRef.current = undefined;
|
pendingScreenshotRef.current = undefined;
|
||||||
@@ -1132,7 +1119,7 @@ export function BrowserPane({
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[captureElementScreenshot, grabElementToClipboard],
|
[captureElementScreenshot, screenshotElementToClipboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitAnnotation = useCallback(
|
const submitAnnotation = useCallback(
|
||||||
@@ -1155,15 +1142,15 @@ export function BrowserPane({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startElementSelector = useCallback(
|
const startElementSelector = useCallback(
|
||||||
(mode: "annotate" | "grab") => {
|
(mode: "annotate" | "screenshot") => {
|
||||||
const webview = webviewRef.current;
|
const webview = webviewRef.current;
|
||||||
if (!webview || !domReadyRef.current) return;
|
if (!webview || !domReadyRef.current) return;
|
||||||
// Annotate needs a workspace scope to attach to; grab only copies.
|
// Annotate needs a workspace scope to attach to; screenshot only copies.
|
||||||
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
||||||
selectorModeRef.current = mode;
|
selectorModeRef.current = mode;
|
||||||
pendingScreenshotRef.current = undefined;
|
pendingScreenshotRef.current = undefined;
|
||||||
setPendingSelection(null);
|
setPendingSelection(null);
|
||||||
setSelectorActive(true);
|
setSelectorMode(mode);
|
||||||
|
|
||||||
const js = `
|
const js = `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -1389,11 +1376,11 @@ export function BrowserPane({
|
|||||||
const poll = startSelectorResultPolling({
|
const poll = startSelectorResultPolling({
|
||||||
webview,
|
webview,
|
||||||
onSelection: handleSelectorResult,
|
onSelection: handleSelectorResult,
|
||||||
onDone: () => setSelectorActive(false),
|
onDone: () => setSelectorMode(null),
|
||||||
});
|
});
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.clearInterval(poll);
|
window.clearInterval(poll);
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
if (webviewRef.current !== webview || !domReadyRef.current) {
|
if (webviewRef.current !== webview || !domReadyRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1402,10 +1389,10 @@ export function BrowserPane({
|
|||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSelectorResult, workspaceAttachmentScopeKey],
|
[handleSelectorResult, workspaceAttachmentScopeKey],
|
||||||
@@ -1413,7 +1400,7 @@ export function BrowserPane({
|
|||||||
|
|
||||||
const cancelElementSelector = useCallback(() => {
|
const cancelElementSelector = useCallback(() => {
|
||||||
const webview = webviewRef.current;
|
const webview = webviewRef.current;
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
if (webview && domReadyRef.current) {
|
if (webview && domReadyRef.current) {
|
||||||
try {
|
try {
|
||||||
clearWebviewSelector(webview);
|
clearWebviewSelector(webview);
|
||||||
@@ -1470,12 +1457,12 @@ export function BrowserPane({
|
|||||||
startElementSelector("annotate");
|
startElementSelector("annotate");
|
||||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||||
|
|
||||||
const handleToggleGrab = useCallback(() => {
|
const handleToggleScreenshot = useCallback(() => {
|
||||||
if (selectorActive) {
|
if (selectorActive) {
|
||||||
cancelElementSelector();
|
cancelElementSelector();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startElementSelector("grab");
|
startElementSelector("screenshot");
|
||||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||||
|
|
||||||
const handleOpenDevTools = useCallback(() => {
|
const handleOpenDevTools = useCallback(() => {
|
||||||
@@ -1521,13 +1508,21 @@ export function BrowserPane({
|
|||||||
],
|
],
|
||||||
[browser?.canGoForward],
|
[browser?.canGoForward],
|
||||||
);
|
);
|
||||||
const selectorIconButtonStyle = useCallback(
|
const annotateIconButtonStyle = useCallback(
|
||||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||||
styles.iconButton,
|
styles.iconButton,
|
||||||
selectorActive && styles.selectorActiveButton,
|
selectorMode === "annotate" && styles.selectorActiveButton,
|
||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
],
|
],
|
||||||
[selectorActive],
|
[selectorMode],
|
||||||
|
);
|
||||||
|
const screenshotIconButtonStyle = useCallback(
|
||||||
|
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||||
|
styles.iconButton,
|
||||||
|
selectorMode === "screenshot" && styles.selectorActiveButton,
|
||||||
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
|
],
|
||||||
|
[selectorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const devicePreset = useMemo(
|
const devicePreset = useMemo(
|
||||||
@@ -1633,36 +1628,46 @@ export function BrowserPane({
|
|||||||
onSelect={setDeviceSizeId}
|
onSelect={setDeviceSizeId}
|
||||||
triggerStyle={baseIconButtonStyle}
|
triggerStyle={baseIconButtonStyle}
|
||||||
/>
|
/>
|
||||||
{isDev ? (
|
<ToolbarButton
|
||||||
<ToolbarButton
|
label={t("workspace.browser.controls.openDevTools")}
|
||||||
label={t("workspace.browser.controls.openDevTools")}
|
onPress={handleOpenDevTools}
|
||||||
onPress={handleOpenDevTools}
|
style={baseIconButtonStyle}
|
||||||
style={baseIconButtonStyle}
|
>
|
||||||
>
|
<Wrench size={16} color={theme.colors.foregroundMuted} />
|
||||||
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
|
</ToolbarButton>
|
||||||
</ToolbarButton>
|
|
||||||
) : null}
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={
|
label={
|
||||||
selectorActive
|
selectorMode === "annotate"
|
||||||
? t("workspace.browser.controls.cancelSelector")
|
? t("workspace.browser.controls.cancelSelector")
|
||||||
: t("workspace.browser.controls.selectElement")
|
: t("workspace.browser.controls.annotateElement")
|
||||||
}
|
}
|
||||||
active={selectorActive}
|
active={selectorMode === "annotate"}
|
||||||
onPress={handleToggleElementSelector}
|
onPress={handleToggleElementSelector}
|
||||||
style={selectorIconButtonStyle}
|
style={annotateIconButtonStyle}
|
||||||
>
|
>
|
||||||
<MousePointer2
|
<MousePointer2
|
||||||
size={16}
|
size={16}
|
||||||
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
|
color={
|
||||||
|
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={t("workspace.browser.controls.grabElement")}
|
label={
|
||||||
onPress={handleToggleGrab}
|
selectorMode === "screenshot"
|
||||||
style={baseIconButtonStyle}
|
? t("workspace.browser.controls.cancelSelector")
|
||||||
|
: t("workspace.browser.controls.screenshotElement")
|
||||||
|
}
|
||||||
|
active={selectorMode === "screenshot"}
|
||||||
|
onPress={handleToggleScreenshot}
|
||||||
|
style={screenshotIconButtonStyle}
|
||||||
>
|
>
|
||||||
<Copy size={16} color={theme.colors.foregroundMuted} />
|
<Camera
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1690,45 +1695,6 @@ export function BrowserPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
|
||||||
fix: "workspace.browser.annotate.intents.fix",
|
|
||||||
change: "workspace.browser.annotate.intents.change",
|
|
||||||
question: "workspace.browser.annotate.intents.question",
|
|
||||||
approve: "workspace.browser.annotate.intents.approve",
|
|
||||||
};
|
|
||||||
|
|
||||||
function IntentChip({
|
|
||||||
active,
|
|
||||||
intent,
|
|
||||||
label,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
intent: BrowserAnnotationIntent;
|
|
||||||
label: string;
|
|
||||||
onSelect: (intent: BrowserAnnotationIntent) => void;
|
|
||||||
}) {
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onSelect(intent);
|
|
||||||
}, [intent, onSelect]);
|
|
||||||
const chipStyle = useMemo(() => [styles.intentChip, active && styles.intentChipActive], [active]);
|
|
||||||
const textStyle = useMemo(
|
|
||||||
() => [styles.intentChipText, active && styles.intentChipTextActive],
|
|
||||||
[active],
|
|
||||||
);
|
|
||||||
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={accessibilityState}
|
|
||||||
onPress={handlePress}
|
|
||||||
style={chipStyle}
|
|
||||||
>
|
|
||||||
<Text style={textStyle}>{label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrowserElementAnnotationCard({
|
function BrowserElementAnnotationCard({
|
||||||
selection,
|
selection,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -1740,14 +1706,11 @@ function BrowserElementAnnotationCard({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
|
|
||||||
const commentRef = useRef(comment);
|
const commentRef = useRef(comment);
|
||||||
commentRef.current = comment;
|
commentRef.current = comment;
|
||||||
const intentRef = useRef(intent);
|
|
||||||
intentRef.current = intent;
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
onSubmit({ comment: commentRef.current, intent: intentRef.current });
|
onSubmit({ comment: commentRef.current });
|
||||||
}, [onSubmit]);
|
}, [onSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1792,17 +1755,6 @@ function BrowserElementAnnotationCard({
|
|||||||
<Text numberOfLines={1} style={styles.annotationElement}>
|
<Text numberOfLines={1} style={styles.annotationElement}>
|
||||||
{elementLabel}
|
{elementLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.annotationIntents}>
|
|
||||||
{BROWSER_ANNOTATION_INTENTS.map((option) => (
|
|
||||||
<IntentChip
|
|
||||||
key={option}
|
|
||||||
active={option === intent}
|
|
||||||
intent={option}
|
|
||||||
label={t(INTENT_LABEL_KEYS[option])}
|
|
||||||
onSelect={setIntent}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<ThemedAnnotationInput
|
<ThemedAnnotationInput
|
||||||
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -1814,24 +1766,12 @@ function BrowserElementAnnotationCard({
|
|||||||
value={comment}
|
value={comment}
|
||||||
/>
|
/>
|
||||||
<View style={styles.annotationActions}>
|
<View style={styles.annotationActions}>
|
||||||
<Pressable
|
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||||
accessibilityRole="button"
|
{t("workspace.browser.annotate.cancel")}
|
||||||
onPress={onCancel}
|
</Button>
|
||||||
style={styles.annotationSecondaryButton}
|
<Button variant="default" size="sm" onPress={handleSubmit}>
|
||||||
>
|
{t("workspace.browser.annotate.submit")}
|
||||||
<Text style={styles.annotationSecondaryText}>
|
</Button>
|
||||||
{t("workspace.browser.annotate.cancel")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
style={styles.annotationPrimaryButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.annotationPrimaryText}>
|
|
||||||
{t("workspace.browser.annotate.submit")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1986,31 +1926,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
annotationElement: {
|
annotationElement: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
marginBottom: theme.spacing[2],
|
||||||
annotationIntents: {
|
|
||||||
flexDirection: "row",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
intentChip: {
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
intentChipActive: {
|
|
||||||
borderColor: theme.colors.accent,
|
|
||||||
backgroundColor: `${String(theme.colors.accent)}20`,
|
|
||||||
},
|
|
||||||
intentChipText: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: "500",
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
intentChipTextActive: {
|
|
||||||
color: theme.colors.accent,
|
|
||||||
},
|
},
|
||||||
annotationInput: {
|
annotationInput: {
|
||||||
minHeight: 64,
|
minHeight: 64,
|
||||||
@@ -2029,27 +1945,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
annotationSecondaryButton: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
annotationSecondaryText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: "500",
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
annotationPrimaryButton: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
},
|
|
||||||
annotationPrimaryText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: theme.colors.accentForeground ?? "#ffffff",
|
|
||||||
},
|
|
||||||
unavailableState: {
|
unavailableState: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -2,33 +2,66 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
clearResidentBrowserWebviewsForTests,
|
clearResidentBrowserWebviewsForTests,
|
||||||
ensureResidentBrowserWebview,
|
ensureResidentBrowserWebview,
|
||||||
|
prepareBrowserWebview,
|
||||||
releaseResidentBrowserWebview,
|
releaseResidentBrowserWebview,
|
||||||
removeResidentBrowserWebview,
|
removeResidentBrowserWebview,
|
||||||
takeResidentBrowserWebview,
|
takeResidentBrowserWebview,
|
||||||
} from "./browser-webview-resident";
|
} 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", () => {
|
describe("resident browser webviews", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearResidentBrowserWebviewsForTests();
|
clearResidentBrowserWebviewsForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
it("parks a browser webview in the permanent paintable 1x1 host", () => {
|
||||||
const host = document.createElement("div");
|
const visibleHost = document.createElement("div");
|
||||||
const webview = document.createElement("webview");
|
const webview = document.createElement("webview");
|
||||||
host.appendChild(webview);
|
visibleHost.appendChild(webview);
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(visibleHost);
|
||||||
|
|
||||||
releaseResidentBrowserWebview("browser-a", webview);
|
releaseResidentBrowserWebview("browser-a", webview);
|
||||||
|
|
||||||
expect(host.children).toHaveLength(0);
|
const host = residentHost();
|
||||||
|
expect(visibleHost.children).toHaveLength(0);
|
||||||
|
expect(Array.from(host.children)).toEqual([webview]);
|
||||||
expect(webview.isConnected).toBe(true);
|
expect(webview.isConnected).toBe(true);
|
||||||
expect(webview.style.width).toBe("1280px");
|
expectPermanentHostParking(host);
|
||||||
expect(webview.style.height).toBe("800px");
|
expectResidentWebviewParking(webview);
|
||||||
|
|
||||||
const reused = takeResidentBrowserWebview("browser-a");
|
|
||||||
|
|
||||||
expect(reused).toBe(webview);
|
|
||||||
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||||
@@ -44,6 +77,113 @@ describe("resident browser webviews", () => {
|
|||||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||||
"https://example.com",
|
"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", () => {
|
it("removes a resident webview when its browser tab closes", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
|
|||||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||||
|
|
||||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||||
|
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||||
|
|
||||||
interface BrowserWebviewElement extends HTMLElement {
|
interface BrowserWebviewElement extends HTMLElement {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -21,42 +22,84 @@ function readDocument(): Document | null {
|
|||||||
return typeof document === "undefined" ? null : document;
|
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 {
|
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
applyResidentHostParkingStyle(existing);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = ownerDocument.createElement("div");
|
const host = ownerDocument.createElement("div");
|
||||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
host.id = RESIDENT_BROWSER_HOST_ID;
|
||||||
host.setAttribute("aria-hidden", "true");
|
applyResidentHostParkingStyle(host);
|
||||||
host.style.position = "fixed";
|
|
||||||
host.style.left = "-20000px";
|
|
||||||
host.style.top = "0";
|
|
||||||
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
|
||||||
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
|
||||||
host.style.overflow = "hidden";
|
|
||||||
host.style.opacity = "0";
|
|
||||||
host.style.pointerEvents = "none";
|
|
||||||
ownerDocument.body.appendChild(host);
|
ownerDocument.body.appendChild(host);
|
||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
||||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||||
return element as HTMLElement;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
|
||||||
webview.style.display = "block";
|
if (!browserId) {
|
||||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
|
||||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
}
|
||||||
|
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.border = "0";
|
||||||
webview.style.background = "transparent";
|
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(
|
export function prepareBrowserWebview(
|
||||||
@@ -88,11 +131,15 @@ export function ensureResidentBrowserWebview(input: {
|
|||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||||
if (resident?.isConnected) {
|
if (resident?.isConnected) {
|
||||||
|
releaseResidentBrowserWebview(browserId, resident);
|
||||||
return resident;
|
return resident;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
|
||||||
|
releaseResidentBrowserWebview(browserId, existing);
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +161,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
clearResidentWebviewParkingStyle(webview);
|
||||||
return webview;
|
return webview;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +177,33 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
|||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||||
applyResidentWebviewStyle(webview);
|
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
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 {
|
export function removeResidentBrowserWebview(browserId: string): void {
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||||
if (!normalizedBrowserId) {
|
if (!normalizedBrowserId) {
|
||||||
@@ -141,6 +212,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
|||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||||
resident?.remove();
|
resident?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,5 +221,6 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
|||||||
webview.remove();
|
webview.remove();
|
||||||
}
|
}
|
||||||
residentWebviewsByBrowserId.clear();
|
residentWebviewsByBrowserId.clear();
|
||||||
|
residentWebviewSizesByBrowserId.clear();
|
||||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import {
|
import {
|
||||||
describeCron,
|
describeCron,
|
||||||
everyMsToParts,
|
everyMsToParts,
|
||||||
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
|||||||
value={intervalUnit}
|
value={intervalUnit}
|
||||||
onValueChange={handleUnitChange}
|
onValueChange={handleUnitChange}
|
||||||
options={UNIT_OPTIONS}
|
options={UNIT_OPTIONS}
|
||||||
style={styles.unitControl}
|
|
||||||
testID="cadence-interval-unit"
|
testID="cadence-interval-unit"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -283,8 +281,6 @@ function CronPresetChip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
// Both cadence segmented controls hug their options and stand at the form's
|
// The mode toggle hugs its options at the left rather than stretching to a
|
||||||
// field height, so the interval row reads as input + toggle rather than a
|
// full-width track; the interval row then reads as input + toggle.
|
||||||
// full-width track with the controls floating inside it.
|
|
||||||
modeControl: {
|
modeControl: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
height: 44,
|
|
||||||
},
|
|
||||||
unitControl: {
|
|
||||||
height: 44,
|
|
||||||
},
|
},
|
||||||
presetRow: {
|
presetRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
chipHover: {
|
chipHover: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
// Selected preset reads as a chosen surface, not a second accent fill
|
||||||
|
// competing with the sheet's primary CTA.
|
||||||
chipSelected: {
|
chipSelected: {
|
||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.surface3,
|
||||||
borderColor: theme.colors.accent,
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
chipLabel: {
|
chipLabel: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
chipLabelSelected: {
|
chipLabelSelected: {
|
||||||
color: theme.colors.accentForeground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
cronInput: {
|
cronInput: {
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontFamily: MONOSPACE_FONT,
|
fontFamily: theme.fontFamily.mono,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.palette.red[300],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||||
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
|
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
|
import {
|
||||||
|
buildScheduleProjectTargets,
|
||||||
|
PROJECT_OPTION_PREFIX,
|
||||||
|
type ScheduleProjectTarget,
|
||||||
|
} from "@/schedules/schedule-project-targets";
|
||||||
import { validateCron } from "@/utils/schedule-format";
|
import { validateCron } from "@/utils/schedule-format";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
|
|||||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
||||||
const PROJECT_OPTION_PREFIX = "project:";
|
|
||||||
|
|
||||||
export interface ScheduleFormSheetProps {
|
export interface ScheduleFormSheetProps {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
|
|||||||
schedule?: ScheduleSummary;
|
schedule?: ScheduleSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleProjectTarget {
|
|
||||||
optionId: string;
|
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleProjectOptions {
|
interface ScheduleProjectOptions {
|
||||||
targets: ScheduleProjectTarget[];
|
targets: ScheduleProjectTarget[];
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
@@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProjectOptionId(serverId: string, projectKey: string): string {
|
|
||||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProjectOptionTestId(optionId: string): string {
|
function buildProjectOptionTestId(optionId: string): string {
|
||||||
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
||||||
return `schedule-project-option-${targetKey}`;
|
return `schedule-project-option-${targetKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
||||||
const targets: ScheduleProjectTarget[] = [];
|
const targets = buildScheduleProjectTargets(projects);
|
||||||
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
|
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
|
||||||
const options: ComboboxOption[] = [];
|
const options: ComboboxOption[] = targets.map((target) => ({
|
||||||
|
id: target.optionId,
|
||||||
for (const project of projects) {
|
label: target.projectName,
|
||||||
for (const host of project.hosts) {
|
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
|
||||||
const cwd = host.repoRoot.trim();
|
}));
|
||||||
if (!host.isOnline || !cwd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const target: ScheduleProjectTarget = {
|
|
||||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
cwd,
|
|
||||||
};
|
|
||||||
targets.push(target);
|
|
||||||
targetByOptionId.set(target.optionId, target);
|
|
||||||
options.push({
|
|
||||||
id: target.optionId,
|
|
||||||
label: target.projectName,
|
|
||||||
description: `${target.serverName} - ${shortenPath(cwd)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { targets, options, targetByOptionId };
|
return { targets, options, targetByOptionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
|
|||||||
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMaxRuns(raw: string): number | null {
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmitScheduleForm(input: {
|
||||||
|
isAgentTarget: boolean;
|
||||||
|
isEdit: boolean;
|
||||||
|
promptTrimmed: string;
|
||||||
|
cadenceError: string | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
selectedModelIsValid: boolean;
|
||||||
|
hasWorkingDir: boolean;
|
||||||
|
hasSelectedProject: boolean;
|
||||||
|
}): boolean {
|
||||||
|
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
|
||||||
|
// non-empty stored cwd; create requires a matched project.
|
||||||
|
if (input.isAgentTarget) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!input.selectedModelIsValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
|
||||||
|
}
|
||||||
|
|
||||||
export function ScheduleFormSheet({
|
export function ScheduleFormSheet({
|
||||||
serverId,
|
serverId,
|
||||||
visible,
|
visible,
|
||||||
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
|
|||||||
schedule,
|
schedule,
|
||||||
}: ScheduleFormSheetProps): ReactElement {
|
}: ScheduleFormSheetProps): ReactElement {
|
||||||
const isEdit = mode === "edit";
|
const isEdit = mode === "edit";
|
||||||
const editConfig = newAgentConfig(schedule);
|
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
|
||||||
|
// (service.ts rejects newAgentConfig for them), so the form drops the
|
||||||
|
// project/model/mode pickers and shows the target agent read-only instead.
|
||||||
|
const isAgentTarget = isEdit && schedule?.target.type === "agent";
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
|
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
||||||
|
|
||||||
|
const agentTargetLabel = useMemo(() => {
|
||||||
|
if (!schedule || schedule.target.type !== "agent") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { agentId } = schedule.target;
|
||||||
|
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
|
||||||
|
if (!agent) {
|
||||||
|
return "Agent unavailable";
|
||||||
|
}
|
||||||
|
return agent.title?.trim() || "Untitled agent";
|
||||||
|
}, [agents, schedule, serverId]);
|
||||||
|
|
||||||
const onlineServerIds = useMemo(
|
const onlineServerIds = useMemo(
|
||||||
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
||||||
[projectOptions.targets],
|
[projectOptions.targets],
|
||||||
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
|
|||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
clearProviderSelectionFromUser,
|
clearProviderSelectionFromUser,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
|
setSelectedServerId,
|
||||||
setSelectedServerIdFromUser,
|
setSelectedServerIdFromUser,
|
||||||
|
setWorkingDir,
|
||||||
setWorkingDirFromUser,
|
setWorkingDirFromUser,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
modelSelectorProviders,
|
modelSelectorProviders,
|
||||||
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
|
|||||||
|
|
||||||
const handleSelectProject = useCallback(
|
const handleSelectProject = useCallback(
|
||||||
(target: ScheduleProjectTarget) => {
|
(target: ScheduleProjectTarget) => {
|
||||||
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
|
// Compare against the current server, not the matched target: an unmatched
|
||||||
|
// stored cwd has no target but still lives on a host, and switching hosts
|
||||||
|
// must still clear a provider/model that may not exist on the new one.
|
||||||
|
if (selectedServerId && selectedServerId !== target.serverId) {
|
||||||
clearProviderSelectionFromUser();
|
clearProviderSelectionFromUser();
|
||||||
}
|
}
|
||||||
setSelectedServerIdFromUser(target.serverId);
|
setSelectedServerIdFromUser(target.serverId);
|
||||||
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
clearProviderSelectionFromUser,
|
clearProviderSelectionFromUser,
|
||||||
selectedProjectTarget,
|
selectedServerId,
|
||||||
setSelectedServerIdFromUser,
|
setSelectedServerIdFromUser,
|
||||||
setWorkingDirFromUser,
|
setWorkingDirFromUser,
|
||||||
],
|
],
|
||||||
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
|
|||||||
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setFieldResetKey((key) => key + 1);
|
setFieldResetKey((key) => key + 1);
|
||||||
|
// The sheet stays mounted, and the form reducer's reset-on-close only
|
||||||
|
// clears user-modified flags — not the picker values — so a create opened
|
||||||
|
// after an edit would inherit that schedule's server/cwd (including a
|
||||||
|
// stale ghost path). Clear them so create always starts fresh; provider
|
||||||
|
// and model re-resolve from preferences.
|
||||||
|
if (!isEdit) {
|
||||||
|
setSelectedServerId(null);
|
||||||
|
setWorkingDir("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wasVisibleRef.current = visible;
|
wasVisibleRef.current = visible;
|
||||||
}, [visible, schedule]);
|
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
|
||||||
|
|
||||||
const promptTrimmed = prompt.trim();
|
const promptTrimmed = prompt.trim();
|
||||||
|
const trimmedWorkingDir = workingDir.trim();
|
||||||
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
||||||
const selectedModelIsValid = isSelectedModelValidForProviders({
|
const selectedModelIsValid = isSelectedModelValidForProviders({
|
||||||
providers: modelSelectorProviders,
|
providers: modelSelectorProviders,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
});
|
});
|
||||||
const canSubmit =
|
const canSubmit = canSubmitScheduleForm({
|
||||||
promptTrimmed.length > 0 &&
|
isAgentTarget,
|
||||||
selectedModelIsValid &&
|
isEdit,
|
||||||
Boolean(selectedProjectTarget) &&
|
promptTrimmed,
|
||||||
cadenceError === null &&
|
cadenceError,
|
||||||
!isSubmitting;
|
isSubmitting,
|
||||||
|
selectedModelIsValid,
|
||||||
|
hasWorkingDir: trimmedWorkingDir.length > 0,
|
||||||
|
hasSelectedProject: Boolean(selectedProjectTarget),
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
|
||||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||||
return;
|
if (!schedule) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
setSubmitError(null);
|
await updateSchedule({
|
||||||
try {
|
id: schedule.id,
|
||||||
await persistFormPreferences();
|
name: name.trim() || null,
|
||||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
prompt: promptTrimmed,
|
||||||
const maxRunsValue =
|
cadence,
|
||||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
maxRuns: parseMaxRuns(maxRuns),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
|
||||||
|
|
||||||
if (isEdit && schedule) {
|
// New-agent target: submit the current working directory. On edit an untouched
|
||||||
await updateSchedule({
|
// picker leaves this as the stored cwd, so it round-trips unchanged.
|
||||||
id: schedule.id,
|
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||||
name: name.trim() || null,
|
if (!selectedProvider || !trimmedWorkingDir) {
|
||||||
prompt: promptTrimmed,
|
return false;
|
||||||
cadence,
|
|
||||||
newAgentConfig: {
|
|
||||||
provider: selectedProvider,
|
|
||||||
model: selectedModel || null,
|
|
||||||
modeId: selectedMode || null,
|
|
||||||
cwd: selectedProjectTarget.cwd,
|
|
||||||
},
|
|
||||||
maxRuns: maxRunsValue,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await createSchedule({
|
|
||||||
prompt: promptTrimmed,
|
|
||||||
name: name.trim() || undefined,
|
|
||||||
cadence,
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: selectedProvider,
|
|
||||||
cwd: selectedProjectTarget.cwd,
|
|
||||||
model: selectedModel || undefined,
|
|
||||||
modeId: selectedMode || undefined,
|
|
||||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
|
||||||
title: name.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
setSubmitError(toErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
|
await persistFormPreferences();
|
||||||
|
const maxRunsValue = parseMaxRuns(maxRuns);
|
||||||
|
if (isEdit && schedule) {
|
||||||
|
await updateSchedule({
|
||||||
|
id: schedule.id,
|
||||||
|
name: name.trim() || null,
|
||||||
|
prompt: promptTrimmed,
|
||||||
|
cadence,
|
||||||
|
newAgentConfig: {
|
||||||
|
provider: selectedProvider,
|
||||||
|
model: selectedModel || null,
|
||||||
|
modeId: selectedMode || null,
|
||||||
|
cwd: trimmedWorkingDir,
|
||||||
|
},
|
||||||
|
maxRuns: maxRunsValue,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await createSchedule({
|
||||||
|
prompt: promptTrimmed,
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
cadence,
|
||||||
|
target: {
|
||||||
|
type: "new-agent",
|
||||||
|
config: {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: trimmedWorkingDir,
|
||||||
|
model: selectedModel || undefined,
|
||||||
|
modeId: selectedMode || undefined,
|
||||||
|
thinkingOptionId: selectedThinkingOptionId || undefined,
|
||||||
|
title: name.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}, [
|
}, [
|
||||||
cadence,
|
cadence,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
isEdit,
|
isEdit,
|
||||||
maxRuns,
|
maxRuns,
|
||||||
name,
|
name,
|
||||||
onClose,
|
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
promptTrimmed,
|
promptTrimmed,
|
||||||
schedule,
|
schedule,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProjectTarget,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
|
trimmedWorkingDir,
|
||||||
updateSchedule,
|
updateSchedule,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!promptTrimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
|
||||||
|
if (submitted) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
|
||||||
|
|
||||||
const handleSubmitPress = useCallback(() => {
|
const handleSubmitPress = useCallback(() => {
|
||||||
void handleSubmit();
|
void handleSubmit();
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
{isAgentTarget ? (
|
||||||
<Text style={styles.label}>Project</Text>
|
<View style={styles.field}>
|
||||||
<ProjectField
|
<Text style={styles.label}>Target</Text>
|
||||||
options={projectOptions.options}
|
<View style={styles.readonlyField} testID="schedule-agent-target">
|
||||||
targetByOptionId={projectOptions.targetByOptionId}
|
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||||
value={selectedProjectOptionId}
|
{agentTargetLabel}
|
||||||
selectedTarget={selectedProjectTarget}
|
</Text>
|
||||||
onSelect={handleSelectProject}
|
</View>
|
||||||
/>
|
<Text style={styles.hint}>Runs against this existing agent.</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Project</Text>
|
||||||
|
<ProjectField
|
||||||
|
options={projectOptions.options}
|
||||||
|
targetByOptionId={projectOptions.targetByOptionId}
|
||||||
|
value={selectedProjectOptionId}
|
||||||
|
selectedTarget={selectedProjectTarget}
|
||||||
|
fallbackCwd={workingDir}
|
||||||
|
onSelect={handleSelectProject}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Model</Text>
|
<Text style={styles.label}>Model</Text>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providers={modelSelectorProviders}
|
providers={modelSelectorProviders}
|
||||||
selectedProvider={selectedProvider ?? ""}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={setProviderAndModelFromUser}
|
onSelect={setProviderAndModelFromUser}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
renderTrigger={renderModelTrigger}
|
renderTrigger={renderModelTrigger}
|
||||||
triggerFill
|
triggerFill
|
||||||
serverId={mutationServerId}
|
serverId={mutationServerId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{modeOptions.length > 0 ? (
|
{modeOptions.length > 0 ? (
|
||||||
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
|
<ModeField
|
||||||
) : null}
|
options={modeOptions}
|
||||||
|
selectedMode={selectedMode}
|
||||||
|
onSelect={setModeFromUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Cadence</Text>
|
<Text style={styles.label}>Cadence</Text>
|
||||||
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
|
|||||||
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{editConfig === null && isEdit ? (
|
|
||||||
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
@@ -594,12 +667,15 @@ function ProjectField({
|
|||||||
targetByOptionId,
|
targetByOptionId,
|
||||||
value,
|
value,
|
||||||
selectedTarget,
|
selectedTarget,
|
||||||
|
fallbackCwd,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||||
value: string;
|
value: string;
|
||||||
selectedTarget: ScheduleProjectTarget | null;
|
selectedTarget: ScheduleProjectTarget | null;
|
||||||
|
/** Stored cwd for an edited schedule whose path matches no known project. */
|
||||||
|
fallbackCwd: string;
|
||||||
onSelect: (target: ScheduleProjectTarget) => void;
|
onSelect: (target: ScheduleProjectTarget) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
@@ -629,7 +705,13 @@ function ProjectField({
|
|||||||
[open],
|
[open],
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayValue = selectedTarget?.projectName ?? "Select project";
|
// Honest hydration: a stored cwd that matches no known project shows the
|
||||||
|
// shortened path itself (not the blank "Select project"), and stays put until
|
||||||
|
// the user deliberately picks a project.
|
||||||
|
const storedPath = fallbackCwd.trim();
|
||||||
|
const displayValue =
|
||||||
|
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
|
||||||
|
const isPlaceholder = !selectedTarget && !storedPath;
|
||||||
const description = selectedTarget
|
const description = selectedTarget
|
||||||
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
||||||
: null;
|
: null;
|
||||||
@@ -662,7 +744,7 @@ function ProjectField({
|
|||||||
testID="schedule-project-trigger"
|
testID="schedule-project-trigger"
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.palette.red[300],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
|
readonlyField: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
selectTrigger: {
|
selectTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||||
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
|
|||||||
|
|
||||||
interface ScheduleRowProps extends ScheduleRowActions {
|
interface ScheduleRowProps extends ScheduleRowActions {
|
||||||
schedule: ScheduleSummary;
|
schedule: ScheduleSummary;
|
||||||
|
/** Client-derived target line (agent title / project / shortened path). */
|
||||||
|
targetLabel: string;
|
||||||
|
/** Provider glyph, resolved from the schedule config or the target agent. */
|
||||||
|
provider: string | null;
|
||||||
|
/** Client-derived state — the single source for the badge and next-run copy. */
|
||||||
|
state: ScheduleDerivedState;
|
||||||
|
/** Host name, rendered when the list spans more than one host. */
|
||||||
|
serverName?: string;
|
||||||
|
/** True when only one host exists and the host name would be redundant. */
|
||||||
|
singleHost?: boolean;
|
||||||
pending?: ScheduleRowPending;
|
pending?: ScheduleRowPending;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
function stateBadge(state: ScheduleDerivedState): {
|
||||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
label: string;
|
||||||
|
variant: "success" | "error" | "muted";
|
||||||
|
} {
|
||||||
|
switch (state) {
|
||||||
|
case "active":
|
||||||
|
return { label: "Active", variant: "success" };
|
||||||
|
case "paused":
|
||||||
|
return { label: "Paused", variant: "muted" };
|
||||||
|
case "expired":
|
||||||
|
return { label: "Expired", variant: "muted" };
|
||||||
|
case "finished":
|
||||||
|
return { label: "Finished", variant: "muted" };
|
||||||
|
case "targetGone":
|
||||||
|
return { label: "Target gone", variant: "error" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveModelLabel(schedule: ScheduleSummary): string {
|
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
// was created, when it last ran, and (only while it can still run) when it runs
|
||||||
return schedule.target.config.model;
|
// next. Status lives on the badge, never repeated here.
|
||||||
|
function buildMeta(
|
||||||
|
schedule: ScheduleSummary,
|
||||||
|
state: ScheduleDerivedState,
|
||||||
|
serverName: string | undefined,
|
||||||
|
singleHost: boolean,
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
formatCadence(schedule.cadence),
|
||||||
|
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||||
|
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||||
|
];
|
||||||
|
if (state === "active") {
|
||||||
|
const next = formatNextRun(schedule.nextRunAt);
|
||||||
|
if (next) {
|
||||||
|
parts.push(`Next run ${next}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "Default model";
|
if (serverName && !singleHost) {
|
||||||
}
|
parts.unshift(serverName);
|
||||||
|
|
||||||
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
|
|
||||||
return status === "active" ? "success" : "muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: ScheduleSummary["status"]): string {
|
|
||||||
if (status === "active") {
|
|
||||||
return "Active";
|
|
||||||
}
|
}
|
||||||
if (status === "paused") {
|
return parts.join(" · ");
|
||||||
return "Paused";
|
|
||||||
}
|
|
||||||
return "Completed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRunLabel(schedule: ScheduleSummary): string {
|
|
||||||
if (schedule.status === "paused") {
|
|
||||||
return "Paused";
|
|
||||||
}
|
|
||||||
if (schedule.status === "completed") {
|
|
||||||
return "Completed";
|
|
||||||
}
|
|
||||||
return formatNextRun(schedule.nextRunAt) || "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||||
@@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
|
|||||||
*/
|
*/
|
||||||
export function ScheduleRow({
|
export function ScheduleRow({
|
||||||
schedule,
|
schedule,
|
||||||
|
targetLabel,
|
||||||
|
provider,
|
||||||
|
state,
|
||||||
|
serverName,
|
||||||
|
singleHost,
|
||||||
pending,
|
pending,
|
||||||
isFirst,
|
isFirst,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -126,15 +152,10 @@ export function ScheduleRow({
|
|||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const provider = resolveProvider(schedule);
|
|
||||||
const title = resolveScheduleTitle(schedule);
|
const title = resolveScheduleTitle(schedule);
|
||||||
const meta = [
|
const badge = stateBadge(state);
|
||||||
resolveModelLabel(schedule),
|
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||||
formatCadence(schedule.cadence),
|
const canRun = state === "active" || state === "paused";
|
||||||
nextRunLabel(schedule),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ");
|
|
||||||
|
|
||||||
const rowStyle = useCallback(
|
const rowStyle = useCallback(
|
||||||
({ pressed }: PressableStateCallbackType) => [
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
@@ -168,6 +189,9 @@ export function ScheduleRow({
|
|||||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.target} numberOfLines={1}>
|
||||||
|
{targetLabel}
|
||||||
|
</Text>
|
||||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -175,12 +199,10 @@ export function ScheduleRow({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.trailing}>
|
<View style={styles.trailing}>
|
||||||
<StatusBadge
|
<StatusBadge label={badge.label} variant={badge.variant} />
|
||||||
label={statusLabel(schedule.status)}
|
|
||||||
variant={statusVariant(schedule.status)}
|
|
||||||
/>
|
|
||||||
<ScheduleKebabMenu
|
<ScheduleKebabMenu
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
canRun={canRun}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onPause={onPause}
|
onPause={onPause}
|
||||||
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
|
|||||||
|
|
||||||
function ScheduleKebabMenu({
|
function ScheduleKebabMenu({
|
||||||
schedule,
|
schedule,
|
||||||
|
canRun,
|
||||||
pending,
|
pending,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPause,
|
onPause,
|
||||||
onResume,
|
onResume,
|
||||||
onRunNow,
|
onRunNow,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
}: Pick<
|
||||||
|
ScheduleRowProps,
|
||||||
|
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
|
||||||
|
> & {
|
||||||
|
canRun: boolean;
|
||||||
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
|
|||||||
{schedule.status === "paused" ? (
|
{schedule.status === "paused" ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={resumeLeading}
|
leading={resumeLeading}
|
||||||
|
disabled={!canRun}
|
||||||
status={pending?.resume ? "pending" : "idle"}
|
status={pending?.resume ? "pending" : "idle"}
|
||||||
pendingLabel="Resuming..."
|
pendingLabel="Resuming..."
|
||||||
onSelect={onResume}
|
onSelect={onResume}
|
||||||
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={pauseLeading}
|
leading={pauseLeading}
|
||||||
disabled={schedule.status === "completed"}
|
disabled={schedule.status === "completed" || !canRun}
|
||||||
status={pending?.pause ? "pending" : "idle"}
|
status={pending?.pause ? "pending" : "idle"}
|
||||||
pendingLabel="Pausing..."
|
pendingLabel="Pausing..."
|
||||||
onSelect={onPause}
|
onSelect={onPause}
|
||||||
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={runLeading}
|
leading={runLeading}
|
||||||
|
disabled={!canRun}
|
||||||
status={pending?.runNow ? "pending" : "idle"}
|
status={pending?.runNow ? "pending" : "idle"}
|
||||||
pendingLabel="Starting..."
|
pendingLabel="Starting..."
|
||||||
onSelect={onRunNow}
|
onSelect={onRunNow}
|
||||||
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
target: {
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
trailing: {
|
trailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -3,44 +3,48 @@ import { View } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||||
|
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
||||||
|
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
|
/** A schedule plus the client-derived fields the row renders. */
|
||||||
|
export interface ScheduleRowView {
|
||||||
|
schedule: AggregatedSchedule;
|
||||||
|
targetLabel: string;
|
||||||
|
provider: string | null;
|
||||||
|
state: ScheduleDerivedState;
|
||||||
|
serverName: string;
|
||||||
|
/** True when only one host exists, so the host name is redundant in rows. */
|
||||||
|
singleHost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SchedulesTableProps {
|
interface SchedulesTableProps {
|
||||||
serverId: string;
|
rows: ScheduleRowView[];
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
/**
|
/**
|
||||||
* The form sheet is owned by the screen (it serves both create and edit and
|
* The form sheet is owned by the screen (it serves both create and edit and
|
||||||
* shares the header's "New schedule" button), so the table delegates edit
|
* shares the screen's "New schedule" button), so the table delegates edit
|
||||||
* upward rather than mounting a second sheet here.
|
* upward rather than mounting a second sheet here.
|
||||||
*/
|
*/
|
||||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The schedules list: a single settings-style card of rows in a centered,
|
* The schedules list: a single settings-style card of rows across every
|
||||||
* width-constrained reading column, matching the projects list. Owns row-level
|
* connected host, in a full-width list matching the History screen. Rows own
|
||||||
* actions (pause/resume/run/delete via the mutations hook + a destructive
|
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
|
||||||
* confirm for delete) and delegates editing to the parent.
|
* a destructive confirm) and delegate editing upward.
|
||||||
*/
|
*/
|
||||||
export function SchedulesTable({
|
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
|
||||||
serverId,
|
|
||||||
schedules,
|
|
||||||
onEditSchedule,
|
|
||||||
}: SchedulesTableProps): ReactElement {
|
|
||||||
const mutations = useScheduleMutations({ serverId });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.listContent} testID="schedules-table">
|
<View style={styles.listContent} testID="schedules-table">
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{schedules.map((schedule, index) => (
|
{rows.map((row, index) => (
|
||||||
<SchedulesTableRow
|
<SchedulesTableRow
|
||||||
key={schedule.id}
|
key={`${row.schedule.serverId}:${row.schedule.id}`}
|
||||||
schedule={schedule}
|
row={row}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
mutations={mutations}
|
|
||||||
onEditSchedule={onEditSchedule}
|
onEditSchedule={onEditSchedule}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -50,28 +54,26 @@ export function SchedulesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-row wrapper owns local in-flight state and binds the table's mutation
|
// Per-row wrapper owns local in-flight state and binds mutations to this
|
||||||
// callbacks to this schedule. Local state keeps pending precise to the acting
|
// schedule's host. Local state keeps pending precise to the acting row even
|
||||||
// row even when several rows are acted on at once (the mutations hook exposes
|
// when several rows are acted on at once (the mutations hook exposes only a
|
||||||
// only a single global pending flag per action).
|
// single global pending flag per action).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
|
||||||
|
|
||||||
const NO_PENDING: ScheduleRowPending = {};
|
const NO_PENDING: ScheduleRowPending = {};
|
||||||
|
|
||||||
function SchedulesTableRow({
|
function SchedulesTableRow({
|
||||||
schedule,
|
row,
|
||||||
isFirst,
|
isFirst,
|
||||||
mutations,
|
|
||||||
onEditSchedule,
|
onEditSchedule,
|
||||||
}: {
|
}: {
|
||||||
schedule: ScheduleSummary;
|
row: ScheduleRowView;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
mutations: ScheduleMutations;
|
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { id } = schedule;
|
const { schedule } = row;
|
||||||
|
const { id, serverId } = schedule;
|
||||||
|
const mutations = useScheduleMutations({ serverId });
|
||||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
||||||
|
|
||||||
const runAction = useCallback(
|
const runAction = useCallback(
|
||||||
@@ -127,6 +129,11 @@ function SchedulesTableRow({
|
|||||||
return (
|
return (
|
||||||
<ScheduleRow
|
<ScheduleRow
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
targetLabel={row.targetLabel}
|
||||||
|
provider={row.provider}
|
||||||
|
state={row.state}
|
||||||
|
serverName={row.serverName}
|
||||||
|
singleHost={row.singleHost}
|
||||||
isFirst={isFirst}
|
isFirst={isFirst}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
@@ -138,14 +145,9 @@ function SchedulesTableRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_MAX_WIDTH = 720;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
// Center the card in a readable column, matching settings and projects.
|
// Full-width list padding matching the History screen.
|
||||||
listContent: {
|
listContent: {
|
||||||
width: "100%",
|
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||||
maxWidth: CONTENT_MAX_WIDTH,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import {
|
import {
|
||||||
@@ -1881,6 +1882,7 @@ function ProjectBlock({
|
|||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
|
supportsMultiplicityByServerId,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -1902,14 +1904,16 @@ function ProjectBlock({
|
|||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
showHostLabels: boolean;
|
showHostLabels: boolean;
|
||||||
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
}) {
|
}) {
|
||||||
const rowModel = useMemo(
|
const rowModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSidebarProjectRowModel({
|
buildSidebarProjectRowModel({
|
||||||
project,
|
project,
|
||||||
collapsed,
|
collapsed,
|
||||||
|
supportsMultiplicityByServerId,
|
||||||
}),
|
}),
|
||||||
[collapsed, project],
|
[collapsed, project, supportsMultiplicityByServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const active = isProjectSelectedByRoute({
|
const active = isProjectSelectedByRoute({
|
||||||
@@ -2062,7 +2066,7 @@ function ProjectBlock({
|
|||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (rowModel.trailingAction.kind === "new_worktree") {
|
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||||
projectChildren = (
|
projectChildren = (
|
||||||
<NewWorkspaceGhostRow
|
<NewWorkspaceGhostRow
|
||||||
project={project}
|
project={project}
|
||||||
@@ -2085,7 +2089,7 @@ function ProjectBlock({
|
|||||||
chevron={rowModel.chevron}
|
chevron={rowModel.chevron}
|
||||||
onPress={handleToggleCollapsed}
|
onPress={handleToggleCollapsed}
|
||||||
worktreeTarget={
|
worktreeTarget={
|
||||||
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
|
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
|
||||||
}
|
}
|
||||||
isProjectActive={active}
|
isProjectActive={active}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -2106,6 +2110,7 @@ function ProjectBlock({
|
|||||||
|
|
||||||
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||||
|
|
||||||
|
// oxlint-disable-next-line complexity
|
||||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||||
return (
|
return (
|
||||||
previous.project === next.project &&
|
previous.project === next.project &&
|
||||||
@@ -2117,6 +2122,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||||
previous.showHostLabels === next.showHostLabels &&
|
previous.showHostLabels === next.showHostLabels &&
|
||||||
|
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||||
previous.parentGestureRef === next.parentGestureRef &&
|
previous.parentGestureRef === next.parentGestureRef &&
|
||||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||||
@@ -2183,6 +2189,8 @@ export function SidebarWorkspaceList({
|
|||||||
}
|
}
|
||||||
return labels;
|
return labels;
|
||||||
}, [hosts]);
|
}, [hosts]);
|
||||||
|
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
|
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||||
|
|
||||||
const content =
|
const content =
|
||||||
@@ -2208,6 +2216,7 @@ export function SidebarWorkspaceList({
|
|||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostLabelByServerId={hostLabelByServerId}
|
||||||
showHostLabels={showHostLabels}
|
showHostLabels={showHostLabels}
|
||||||
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2256,6 +2265,7 @@ function ProjectModeList({
|
|||||||
pathname,
|
pathname,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
|
supportsMultiplicityByServerId,
|
||||||
}: Omit<
|
}: Omit<
|
||||||
SidebarWorkspaceListProps,
|
SidebarWorkspaceListProps,
|
||||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||||
@@ -2263,6 +2273,7 @@ function ProjectModeList({
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
showHostLabels: boolean;
|
showHostLabels: boolean;
|
||||||
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
@@ -2451,6 +2462,7 @@ function ProjectModeList({
|
|||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostLabelByServerId={hostLabelByServerId}
|
||||||
showHostLabels={showHostLabels}
|
showHostLabels={showHostLabels}
|
||||||
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -2461,6 +2473,7 @@ function ProjectModeList({
|
|||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
|
supportsMultiplicityByServerId,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
sm: {
|
sm: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[1.5],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
segmentSm: {
|
segmentSm: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[1.5],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
},
|
},
|
||||||
segmentMd: {
|
segmentMd: {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
|
|||||||
tag: attachment.attachment.tag,
|
tag: attachment.attachment.tag,
|
||||||
text: attachment.attachment.text,
|
text: attachment.attachment.text,
|
||||||
html: attachment.attachment.outerHTML,
|
html: attachment.attachment.outerHTML,
|
||||||
intent: attachment.attachment.intent ?? null,
|
|
||||||
comment: attachment.attachment.comment ?? null,
|
comment: attachment.attachment.comment ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Auggie CLI",
|
title: "Auggie CLI",
|
||||||
description:
|
description:
|
||||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||||
version: "0.31.0",
|
version: "0.32.0",
|
||||||
iconId: "auggie",
|
iconId: "auggie",
|
||||||
installLink: "https://www.augmentcode.com/",
|
installLink: "https://www.augmentcode.com/",
|
||||||
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
|
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
||||||
env: {
|
env: {
|
||||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||||
},
|
},
|
||||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.34",
|
version: "3.0.37",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.34", "--acp"],
|
command: ["npx", "-y", "cline@3.0.37", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
title: "Codebuddy Code",
|
title: "Codebuddy Code",
|
||||||
description: "Tencent Cloud's official intelligent coding tool",
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
version: "2.114.0",
|
version: "2.116.0",
|
||||||
iconId: "codebuddy-code",
|
iconId: "codebuddy-code",
|
||||||
installLink: "https://www.codebuddy.cn/cli/",
|
installLink: "https://www.codebuddy.cn/cli/",
|
||||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
|
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codewhale",
|
id: "codewhale",
|
||||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.12",
|
version: "0.2.19",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
|
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
description:
|
||||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||||
version: "0.4.12",
|
version: "0.4.13",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.161.0",
|
version: "0.164.0",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.8.1",
|
version: "0.8.3",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
|||||||
id: "nova",
|
id: "nova",
|
||||||
title: "Nova",
|
title: "Nova",
|
||||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||||
version: "1.1.22",
|
version: "1.1.24",
|
||||||
iconId: "nova",
|
iconId: "nova",
|
||||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||||
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
|
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "poolside",
|
id: "poolside",
|
||||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.0.33",
|
version: "1.0.37",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.19.3",
|
version: "0.19.6",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
|
|||||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||||
version: "manual",
|
version: "manual",
|
||||||
iconId: "traecli",
|
iconId: "traecli",
|
||||||
installLink: "https://docs.trae.cn/cli",
|
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||||
command: ["traecli", "acp", "serve"],
|
command: ["traecli", "acp", "serve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ export interface DesktopBrowserNewTabRequestEvent {
|
|||||||
|
|
||||||
export interface DesktopBrowserBridge {
|
export interface DesktopBrowserBridge {
|
||||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
||||||
|
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||||
setWorkspaceActiveBrowser?: (input: {
|
setWorkspaceActiveBrowser?: (input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
browserId: string | null;
|
browserId: string | null;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
|
|
||||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||||
clearPartition?: (browserId: string) => Promise<void>;
|
clearPartition?: (browserId: string) => Promise<void>;
|
||||||
executeAutomationCommand?: (
|
executeAutomationCommand?: (
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type {
|
|||||||
} from "@getpaseo/client/internal/daemon-client";
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
||||||
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
|
import type {
|
||||||
|
AggregatedSchedule,
|
||||||
|
FetchAggregatedSchedulesResult,
|
||||||
|
} from "@/schedules/aggregated-schedules";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||||
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScheduleSections(
|
function updateSchedulesData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
updateSection: (
|
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
): void {
|
): void {
|
||||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||||
{ queryKey: schedulesQueryBaseKey },
|
{ queryKey: schedulesQueryBaseKey },
|
||||||
@@ -72,7 +73,7 @@ function updateScheduleSections(
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
return { sections: current.sections.map(updateSection) };
|
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
|
|||||||
status: ScheduleSummary["status"],
|
status: ScheduleSummary["status"],
|
||||||
): void {
|
): void {
|
||||||
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
||||||
updateScheduleSections(queryClient, (section) =>
|
updateSchedulesData(queryClient, (schedules) =>
|
||||||
section.serverId === serverId
|
schedules.map((schedule) =>
|
||||||
? {
|
schedule.serverId === serverId && schedule.id === id
|
||||||
...section,
|
? { ...schedule, status, pausedAt }
|
||||||
schedules: section.schedules.map((schedule) =>
|
: schedule,
|
||||||
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
|
),
|
||||||
),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
||||||
updateScheduleSections(queryClient, (section) =>
|
updateSchedulesData(queryClient, (schedules) =>
|
||||||
section.serverId === serverId
|
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
|
||||||
? {
|
|
||||||
...section,
|
|
||||||
schedules: section.schedules.filter((schedule) => schedule.id !== id),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
fetchAggregatedSchedules,
|
fetchAggregatedSchedules,
|
||||||
|
type AggregatedSchedule,
|
||||||
|
type ScheduleHostError,
|
||||||
type ScheduleHostInput,
|
type ScheduleHostInput,
|
||||||
type ScheduleHostSection,
|
|
||||||
} from "@/schedules/aggregated-schedules";
|
} from "@/schedules/aggregated-schedules";
|
||||||
|
|
||||||
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
|
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
|
||||||
|
|
||||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
export const schedulesQueryBaseKey = ["schedules"] as const;
|
||||||
|
|
||||||
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
|
// Cache identity for the host set. The query also carries the runtime version
|
||||||
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
|
// (below) so it retries as connectivity changes and reliably fetches once a host
|
||||||
|
// comes online — even on a cold deep-link. The full-screen spinner flash that
|
||||||
|
// keying on the version used to cause is prevented by keepPreviousData plus the
|
||||||
|
// isInitialLoad(data === undefined) gate, not by dropping the version.
|
||||||
|
export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||||
|
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSchedulesResult {
|
export interface UseSchedulesResult {
|
||||||
sections: ScheduleHostSection[];
|
schedules: AggregatedSchedule[];
|
||||||
isLoading: boolean;
|
hostErrors: ScheduleHostError[];
|
||||||
|
isInitialLoad: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
|
|||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
);
|
);
|
||||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||||
() =>
|
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||||
hosts.map((host) => ({
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.label,
|
|
||||||
})),
|
|
||||||
[hosts],
|
[hosts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
|
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections: query.data?.sections ?? [],
|
schedules: query.data?.schedules ?? [],
|
||||||
isLoading: query.isLoading,
|
hostErrors: query.data?.hostErrors ?? [],
|
||||||
|
isInitialLoad: query.isLoading && query.data === undefined,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refetch: () => {
|
refetch: () => {
|
||||||
|
|||||||
@@ -422,22 +422,17 @@ export const ar: TranslationResources = {
|
|||||||
enterUrl: "أدخل URL",
|
enterUrl: "أدخل URL",
|
||||||
openDevTools: "افتح أدوات تطوير المتصفح",
|
openDevTools: "افتح أدوات تطوير المتصفح",
|
||||||
cancelSelector: "إلغاء محدد العنصر",
|
cancelSelector: "إلغاء محدد العنصر",
|
||||||
selectElement: "حدد العنصر",
|
annotateElement: "التعليق على العنصر",
|
||||||
grabElement: "نسخ العنصر إلى الحافظة",
|
screenshotElement: "لقطة للعنصر",
|
||||||
grabElementLabel: "العنصر",
|
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
|
||||||
grabFailed: "تعذّر نسخ العنصر",
|
elementCopied: "تم نسخ العنصر إلى الحافظة",
|
||||||
|
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "إرسال ملاحظات إلى الوكيل",
|
title: "التعليق على العنصر",
|
||||||
placeholder: "صف ما الذي ينبغي تغييره…",
|
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
|
||||||
submit: "إرفاق",
|
submit: "إرفاق",
|
||||||
cancel: "إلغاء",
|
cancel: "إلغاء",
|
||||||
intents: {
|
|
||||||
fix: "إصلاح",
|
|
||||||
change: "تغيير",
|
|
||||||
question: "سؤال",
|
|
||||||
approve: "موافقة",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "حجم الجهاز",
|
label: "حجم الجهاز",
|
||||||
|
|||||||
@@ -422,22 +422,17 @@ export const en = {
|
|||||||
enterUrl: "Enter URL",
|
enterUrl: "Enter URL",
|
||||||
openDevTools: "Open browser dev tools",
|
openDevTools: "Open browser dev tools",
|
||||||
cancelSelector: "Cancel element selector",
|
cancelSelector: "Cancel element selector",
|
||||||
selectElement: "Select element",
|
annotateElement: "Annotate element",
|
||||||
grabElement: "Copy element to clipboard",
|
screenshotElement: "Screenshot element",
|
||||||
grabElementLabel: "element",
|
screenshotCopied: "Copied screenshot to clipboard",
|
||||||
grabFailed: "Couldn't copy element",
|
elementCopied: "Copied element to clipboard",
|
||||||
|
screenshotFailed: "Couldn't copy screenshot",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "Send feedback to agent",
|
title: "Annotate element",
|
||||||
placeholder: "Describe what should change…",
|
placeholder: "Message to the agent about this element…",
|
||||||
submit: "Attach",
|
submit: "Attach",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
intents: {
|
|
||||||
fix: "Fix",
|
|
||||||
change: "Change",
|
|
||||||
question: "Question",
|
|
||||||
approve: "Approve",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "Device size",
|
label: "Device size",
|
||||||
|
|||||||
@@ -426,22 +426,17 @@ export const es: TranslationResources = {
|
|||||||
enterUrl: "IngreseURL",
|
enterUrl: "IngreseURL",
|
||||||
openDevTools: "Abrir herramientas de desarrollo del navegador",
|
openDevTools: "Abrir herramientas de desarrollo del navegador",
|
||||||
cancelSelector: "Cancelar selector de elementos",
|
cancelSelector: "Cancelar selector de elementos",
|
||||||
selectElement: "Seleccionar elemento",
|
annotateElement: "Anotar elemento",
|
||||||
grabElement: "Copiar elemento al portapapeles",
|
screenshotElement: "Capturar elemento",
|
||||||
grabElementLabel: "elemento",
|
screenshotCopied: "Captura copiada al portapapeles",
|
||||||
grabFailed: "No se pudo copiar el elemento",
|
elementCopied: "Elemento copiado al portapapeles",
|
||||||
|
screenshotFailed: "No se pudo copiar la captura",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "Enviar comentarios al agente",
|
title: "Anotar elemento",
|
||||||
placeholder: "Describe qué debería cambiar…",
|
placeholder: "Mensaje al agente sobre este elemento…",
|
||||||
submit: "Adjuntar",
|
submit: "Adjuntar",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
intents: {
|
|
||||||
fix: "Corregir",
|
|
||||||
change: "Cambiar",
|
|
||||||
question: "Pregunta",
|
|
||||||
approve: "Aprobar",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "Tamaño del dispositivo",
|
label: "Tamaño del dispositivo",
|
||||||
|
|||||||
@@ -426,22 +426,17 @@ export const fr: TranslationResources = {
|
|||||||
enterUrl: "EntrezURL",
|
enterUrl: "EntrezURL",
|
||||||
openDevTools: "Outils de développement du navigateur ouvert",
|
openDevTools: "Outils de développement du navigateur ouvert",
|
||||||
cancelSelector: "Annuler le sélecteur d'élément",
|
cancelSelector: "Annuler le sélecteur d'élément",
|
||||||
selectElement: "Sélectionner un élément",
|
annotateElement: "Annoter l'élément",
|
||||||
grabElement: "Copier l'élément dans le presse-papiers",
|
screenshotElement: "Capturer l'élément",
|
||||||
grabElementLabel: "élément",
|
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
|
||||||
grabFailed: "Impossible de copier l'élément",
|
elementCopied: "Élément copié dans le presse-papiers",
|
||||||
|
screenshotFailed: "Impossible de copier la capture",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "Envoyer un retour à l'agent",
|
title: "Annoter l'élément",
|
||||||
placeholder: "Décrivez ce qui doit changer…",
|
placeholder: "Message à l'agent concernant cet élément…",
|
||||||
submit: "Joindre",
|
submit: "Joindre",
|
||||||
cancel: "Annuler",
|
cancel: "Annuler",
|
||||||
intents: {
|
|
||||||
fix: "Corriger",
|
|
||||||
change: "Modifier",
|
|
||||||
question: "Question",
|
|
||||||
approve: "Approuver",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "Taille de l'appareil",
|
label: "Taille de l'appareil",
|
||||||
|
|||||||
@@ -426,22 +426,17 @@ export const ja: TranslationResources = {
|
|||||||
enterUrl: "URLを入力",
|
enterUrl: "URLを入力",
|
||||||
openDevTools: "ブラウザ開発ツールを開く",
|
openDevTools: "ブラウザ開発ツールを開く",
|
||||||
cancelSelector: "要素セレクターをキャンセル",
|
cancelSelector: "要素セレクターをキャンセル",
|
||||||
selectElement: "要素を選択",
|
annotateElement: "要素に注釈を付ける",
|
||||||
grabElement: "要素をクリップボードにコピー",
|
screenshotElement: "要素のスクリーンショット",
|
||||||
grabElementLabel: "要素",
|
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
|
||||||
grabFailed: "要素をコピーできませんでした",
|
elementCopied: "要素をクリップボードにコピーしました",
|
||||||
|
screenshotFailed: "スクリーンショットをコピーできませんでした",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "エージェントにフィードバックを送信",
|
title: "要素に注釈を付ける",
|
||||||
placeholder: "変更すべき内容を記述…",
|
placeholder: "この要素についてエージェントへのメッセージ…",
|
||||||
submit: "添付",
|
submit: "添付",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
intents: {
|
|
||||||
fix: "修正",
|
|
||||||
change: "変更",
|
|
||||||
question: "質問",
|
|
||||||
approve: "承認",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "デバイスサイズ",
|
label: "デバイスサイズ",
|
||||||
|
|||||||
@@ -426,22 +426,17 @@ export const ptBR: TranslationResources = {
|
|||||||
enterUrl: "Inserir URL",
|
enterUrl: "Inserir URL",
|
||||||
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
|
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
|
||||||
cancelSelector: "Cancelar seletor de elemento",
|
cancelSelector: "Cancelar seletor de elemento",
|
||||||
selectElement: "Selecionar elemento",
|
annotateElement: "Anotar elemento",
|
||||||
grabElement: "Copiar elemento para a área de transferência",
|
screenshotElement: "Capturar elemento",
|
||||||
grabElementLabel: "elemento",
|
screenshotCopied: "Captura copiada para a área de transferência",
|
||||||
grabFailed: "Não foi possível copiar o elemento",
|
elementCopied: "Elemento copiado para a área de transferência",
|
||||||
|
screenshotFailed: "Não foi possível copiar a captura",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "Enviar feedback ao agente",
|
title: "Anotar elemento",
|
||||||
placeholder: "Descreva o que deve mudar…",
|
placeholder: "Mensagem ao agente sobre este elemento…",
|
||||||
submit: "Anexar",
|
submit: "Anexar",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
intents: {
|
|
||||||
fix: "Corrigir",
|
|
||||||
change: "Alterar",
|
|
||||||
question: "Pergunta",
|
|
||||||
approve: "Aprovar",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "Tamanho do dispositivo",
|
label: "Tamanho do dispositivo",
|
||||||
|
|||||||
@@ -426,22 +426,17 @@ export const ru: TranslationResources = {
|
|||||||
enterUrl: "Введите URL",
|
enterUrl: "Введите URL",
|
||||||
openDevTools: "Открыть инструменты разработки браузера",
|
openDevTools: "Открыть инструменты разработки браузера",
|
||||||
cancelSelector: "Отменить выбор элемента",
|
cancelSelector: "Отменить выбор элемента",
|
||||||
selectElement: "Выберите элемент",
|
annotateElement: "Аннотировать элемент",
|
||||||
grabElement: "Скопировать элемент в буфер обмена",
|
screenshotElement: "Снимок элемента",
|
||||||
grabElementLabel: "элемент",
|
screenshotCopied: "Снимок скопирован в буфер обмена",
|
||||||
grabFailed: "Не удалось скопировать элемент",
|
elementCopied: "Элемент скопирован в буфер обмена",
|
||||||
|
screenshotFailed: "Не удалось скопировать снимок",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "Отправить отзыв агенту",
|
title: "Аннотировать элемент",
|
||||||
placeholder: "Опишите, что нужно изменить…",
|
placeholder: "Сообщение агенту об этом элементе…",
|
||||||
submit: "Прикрепить",
|
submit: "Прикрепить",
|
||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
intents: {
|
|
||||||
fix: "Исправить",
|
|
||||||
change: "Изменить",
|
|
||||||
question: "Вопрос",
|
|
||||||
approve: "Одобрить",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "Размер устройства",
|
label: "Размер устройства",
|
||||||
|
|||||||
@@ -422,22 +422,17 @@ export const zhCN: TranslationResources = {
|
|||||||
enterUrl: "输入 URL",
|
enterUrl: "输入 URL",
|
||||||
openDevTools: "打开浏览器开发者工具",
|
openDevTools: "打开浏览器开发者工具",
|
||||||
cancelSelector: "取消元素选择器",
|
cancelSelector: "取消元素选择器",
|
||||||
selectElement: "选择元素",
|
annotateElement: "标注元素",
|
||||||
grabElement: "复制元素到剪贴板",
|
screenshotElement: "截图元素",
|
||||||
grabElementLabel: "元素",
|
screenshotCopied: "已将截图复制到剪贴板",
|
||||||
grabFailed: "复制元素失败",
|
elementCopied: "已将元素复制到剪贴板",
|
||||||
|
screenshotFailed: "无法复制截图",
|
||||||
},
|
},
|
||||||
annotate: {
|
annotate: {
|
||||||
title: "发送反馈给智能体",
|
title: "标注元素",
|
||||||
placeholder: "描述需要修改的内容…",
|
placeholder: "给智能体关于此元素的留言…",
|
||||||
submit: "附加",
|
submit: "附加",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
intents: {
|
|
||||||
fix: "修复",
|
|
||||||
change: "修改",
|
|
||||||
question: "提问",
|
|
||||||
approve: "认可",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
label: "设备尺寸",
|
label: "设备尺寸",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||||
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -522,10 +523,15 @@ function probeIntervalForConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||||
const desktopBrowserAutomationAvailable =
|
const browserHostAvailable =
|
||||||
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
||||||
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
|
const browserAutomationCapabilities = browserHostAvailable
|
||||||
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
|
? {
|
||||||
|
[CLIENT_CAPS.browserHost]: {
|
||||||
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||||
|
hostKind: "desktop app",
|
||||||
|
},
|
||||||
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
|
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||||
|
|
||||||
export interface ScheduleHostInput {
|
export interface ScheduleHostInput {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
|
|||||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleHostSection {
|
/** A schedule tagged with the host it came from, so the flat list can render a
|
||||||
|
* per-row host label and scope mutations without host sections. */
|
||||||
|
export interface AggregatedSchedule extends ScheduleSummary {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
isOnline: boolean;
|
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesInput {
|
export interface ScheduleHostError {
|
||||||
hosts: ScheduleHostInput[];
|
serverId: string;
|
||||||
runtime: ScheduleRuntime;
|
serverName: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesResult {
|
export interface FetchAggregatedSchedulesResult {
|
||||||
sections: ScheduleHostSection[];
|
schedules: AggregatedSchedule[];
|
||||||
|
hostErrors: ScheduleHostError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FetchAggregatedSchedulesInput {
|
||||||
|
hosts: readonly ScheduleHostInput[];
|
||||||
|
runtime: ScheduleRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch schedules across connected hosts and merge them into one flat list.
|
||||||
|
* Connectivity is checked here at execution time (not pre-filtered by the hook)
|
||||||
|
* so the query — retried as the runtime version changes — reliably picks a host
|
||||||
|
* up the moment it comes online, including on a cold deep-link.
|
||||||
|
*
|
||||||
|
* Offline hosts are skipped. A connected host that fails contributes to
|
||||||
|
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
||||||
|
* every connected host fails do we throw so the screen shows a full error.
|
||||||
|
*/
|
||||||
export async function fetchAggregatedSchedules(
|
export async function fetchAggregatedSchedules(
|
||||||
input: FetchAggregatedSchedulesInput,
|
input: FetchAggregatedSchedulesInput,
|
||||||
): Promise<FetchAggregatedSchedulesResult> {
|
): Promise<FetchAggregatedSchedulesResult> {
|
||||||
const sections = await Promise.all(
|
const schedules: AggregatedSchedule[] = [];
|
||||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
const hostErrors: ScheduleHostError[] = [];
|
||||||
|
let connectedAttempts = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
input.hosts.map(async (host) => {
|
||||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||||
const isOnline = snapshot?.connectionStatus === "online";
|
const isOnline = snapshot?.connectionStatus === "online";
|
||||||
const client = input.runtime.getClient(host.serverId);
|
const client = input.runtime.getClient(host.serverId);
|
||||||
|
|
||||||
if (!client || !isOnline) {
|
if (!client || !isOnline) {
|
||||||
return {
|
return;
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline,
|
|
||||||
schedules: [],
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
connectedAttempts += 1;
|
||||||
try {
|
try {
|
||||||
const payload = await client.scheduleList();
|
const payload = await client.scheduleList();
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
return {
|
for (const schedule of payload.schedules) {
|
||||||
serverId: host.serverId,
|
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
|
||||||
serverName: host.serverName,
|
}
|
||||||
isOnline,
|
|
||||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
hostErrors.push({
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
serverName: host.serverName,
|
serverName: host.serverName,
|
||||||
isOnline,
|
message: toErrorMessage(error),
|
||||||
schedules: [],
|
});
|
||||||
error: toErrorMessage(error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sections };
|
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
|
||||||
|
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { schedules, hostErrors };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
|
||||||
|
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
|
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
|
||||||
|
return {
|
||||||
|
id: "schedule-1",
|
||||||
|
name: "Nightly",
|
||||||
|
prompt: "Run the task",
|
||||||
|
cadence: { type: "every", everyMs: 60_000 },
|
||||||
|
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-07-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||||
|
nextRunAt: "2026-07-02T01:00:00.000Z",
|
||||||
|
lastRunAt: null,
|
||||||
|
pausedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
maxRuns: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(
|
||||||
|
schedule: ScheduleSummary,
|
||||||
|
options?: {
|
||||||
|
agents?: Array<[string, ScheduleTargetAgent]>;
|
||||||
|
projects?: Array<[string, string]>;
|
||||||
|
agentDataLoaded?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return resolveSchedule({
|
||||||
|
schedule,
|
||||||
|
serverId: "host-1",
|
||||||
|
now: NOW,
|
||||||
|
agentsByKey: new Map(options?.agents ?? []),
|
||||||
|
projectNameByCwd: new Map(options?.projects ?? []),
|
||||||
|
agentDataLoaded: options?.agentDataLoaded ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveSchedule state", () => {
|
||||||
|
it("keeps active and paused schedules runnable", () => {
|
||||||
|
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||||
|
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
|
||||||
|
expect(scheduleBucket("active")).toBe("runnable");
|
||||||
|
expect(scheduleBucket("paused")).toBe("runnable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a past expiresAt as expired regardless of status", () => {
|
||||||
|
const result = resolve(
|
||||||
|
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("expired");
|
||||||
|
expect(result.bucket).toBe("ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unparseable expiresAt", () => {
|
||||||
|
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives finished only from completed-and-not-expired", () => {
|
||||||
|
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
|
||||||
|
expect(
|
||||||
|
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
|
||||||
|
).toBe("expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an agent target gone when the client has no such agent", () => {
|
||||||
|
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||||
|
expect(resolve(schedule).state).toBe("targetGone");
|
||||||
|
expect(resolve(schedule).bucket).toBe("ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim gone before the agent directory has loaded", () => {
|
||||||
|
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||||
|
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
|
||||||
|
const paused = makeSchedule({
|
||||||
|
status: "paused",
|
||||||
|
target: { type: "agent", agentId: AGENT_ID },
|
||||||
|
});
|
||||||
|
expect(resolve(paused).state).toBe("targetGone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never claims a new-agent cwd is gone", () => {
|
||||||
|
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSchedule target line", () => {
|
||||||
|
it("names an agent target by its client title and provider", () => {
|
||||||
|
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||||
|
const result = resolve(schedule, {
|
||||||
|
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
|
||||||
|
});
|
||||||
|
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
|
||||||
|
expect(result.state).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Untitled agent when the agent has no title", () => {
|
||||||
|
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||||
|
const result = resolve(schedule, {
|
||||||
|
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
|
||||||
|
});
|
||||||
|
expect(result.target.label).toBe("Untitled agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a gone agent target as unavailable with no glyph", () => {
|
||||||
|
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||||
|
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a new-agent cwd by matched project, else the shortened path", () => {
|
||||||
|
const matched = makeSchedule({
|
||||||
|
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||||
|
});
|
||||||
|
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
|
||||||
|
label: "My Project",
|
||||||
|
provider: "codex",
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmatched = makeSchedule({
|
||||||
|
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
|
||||||
|
});
|
||||||
|
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
|
||||||
|
|
||||||
|
// Derived from existing fields only — no new protocol state. "active"/"paused"
|
||||||
|
// mirror the stored status; the rest are computed truths the daemon does not
|
||||||
|
// spell out in a single field.
|
||||||
|
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
|
||||||
|
|
||||||
|
export type ScheduleBucket = "runnable" | "ended";
|
||||||
|
|
||||||
|
export interface ScheduleTargetAgent {
|
||||||
|
title: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleTargetResolution {
|
||||||
|
/** The target line: agent title, project name, or the shortened cwd. */
|
||||||
|
label: string;
|
||||||
|
/** Provider glyph for the row, when known. */
|
||||||
|
provider: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSchedule {
|
||||||
|
state: ScheduleDerivedState;
|
||||||
|
bucket: ScheduleBucket;
|
||||||
|
target: ScheduleTargetResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveScheduleInput {
|
||||||
|
schedule: ScheduleSummary;
|
||||||
|
serverId: string;
|
||||||
|
now: number;
|
||||||
|
/** Client agent directory keyed by `${serverId}:${agentId}`. */
|
||||||
|
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
|
||||||
|
/** Known project roots keyed by `${serverId}:${cwd}`. */
|
||||||
|
projectNameByCwd: ReadonlyMap<string, string>;
|
||||||
|
/**
|
||||||
|
* Whether the agent directory has finished its first load. While false we do
|
||||||
|
* not claim an agent target is gone — absence would just be a cold cache.
|
||||||
|
*/
|
||||||
|
agentDataLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentKey(serverId: string, agentId: string): string {
|
||||||
|
return `${serverId}:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(schedule: ScheduleSummary, now: number): boolean {
|
||||||
|
if (!schedule.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(schedule.expiresAt);
|
||||||
|
return Number.isFinite(expiresAt) && expiresAt <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
|
||||||
|
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
|
||||||
|
if (schedule.target.type !== "agent" || !agentDataLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
|
||||||
|
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
|
||||||
|
if (schedule.target.type === "agent") {
|
||||||
|
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
|
||||||
|
if (agent) {
|
||||||
|
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
|
||||||
|
}
|
||||||
|
return { label: "Agent unavailable", provider: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
|
||||||
|
provider: schedule.target.config.provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One badge, one truth. Order matters: expiry and a missing target are more
|
||||||
|
// informative than the raw "completed"/"paused" status, so they win.
|
||||||
|
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
||||||
|
const { schedule, now } = input;
|
||||||
|
if (isExpired(schedule, now)) {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
if (isAgentTargetGone(input)) {
|
||||||
|
return "targetGone";
|
||||||
|
}
|
||||||
|
if (schedule.status === "completed") {
|
||||||
|
return "finished";
|
||||||
|
}
|
||||||
|
if (schedule.status === "paused") {
|
||||||
|
return "paused";
|
||||||
|
}
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
|
||||||
|
return state === "active" || state === "paused" ? "runnable" : "ended";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
|
||||||
|
const state = deriveState(input);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
bucket: scheduleBucket(state),
|
||||||
|
target: resolveTarget(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ProjectSummary } from "@/utils/projects";
|
||||||
|
import {
|
||||||
|
buildProjectNameByCwd,
|
||||||
|
buildScheduleProjectTargets,
|
||||||
|
describeScheduleCwd,
|
||||||
|
} from "./schedule-project-targets";
|
||||||
|
|
||||||
|
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||||
|
return {
|
||||||
|
projectKey: "proj",
|
||||||
|
projectName: "Project",
|
||||||
|
hosts: [],
|
||||||
|
totalWorkspaceCount: 0,
|
||||||
|
hostCount: 0,
|
||||||
|
onlineHostCount: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||||
|
return {
|
||||||
|
serverId: "host-1",
|
||||||
|
serverName: "Host 1",
|
||||||
|
isOnline: true,
|
||||||
|
repoRoot: "/tmp/project",
|
||||||
|
workspaceCount: 0,
|
||||||
|
workspaces: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildScheduleProjectTargets", () => {
|
||||||
|
it("emits one target per online host with a repo root", () => {
|
||||||
|
const targets = buildScheduleProjectTargets([
|
||||||
|
makeProject({
|
||||||
|
projectName: "Alpha",
|
||||||
|
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(targets).toHaveLength(2);
|
||||||
|
expect(targets[0]).toMatchObject({
|
||||||
|
serverId: "host-1",
|
||||||
|
cwd: "/tmp/alpha",
|
||||||
|
projectName: "Alpha",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips offline hosts and blank repo roots", () => {
|
||||||
|
const targets = buildScheduleProjectTargets([
|
||||||
|
makeProject({
|
||||||
|
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(targets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeScheduleCwd", () => {
|
||||||
|
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||||
|
const byCwd = buildProjectNameByCwd(
|
||||||
|
buildScheduleProjectTargets([
|
||||||
|
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||||
|
).toBe("Alpha");
|
||||||
|
expect(
|
||||||
|
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||||
|
).toBe("~/api");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ProjectSummary } from "@/utils/projects";
|
||||||
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
|
|
||||||
|
export const PROJECT_OPTION_PREFIX = "project:";
|
||||||
|
|
||||||
|
export interface ScheduleProjectTarget {
|
||||||
|
optionId: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||||
|
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project roots the schedule form can target: one per online host of each
|
||||||
|
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
|
||||||
|
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
|
||||||
|
*/
|
||||||
|
export function buildScheduleProjectTargets(
|
||||||
|
projects: readonly ProjectSummary[],
|
||||||
|
): ScheduleProjectTarget[] {
|
||||||
|
const targets: ScheduleProjectTarget[] = [];
|
||||||
|
for (const project of projects) {
|
||||||
|
for (const host of project.hosts) {
|
||||||
|
const cwd = host.repoRoot.trim();
|
||||||
|
if (!host.isOnline || !cwd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targets.push({
|
||||||
|
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||||
|
serverId: host.serverId,
|
||||||
|
serverName: host.serverName,
|
||||||
|
projectKey: project.projectKey,
|
||||||
|
projectName: project.projectName,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectNameKey(serverId: string, cwd: string): string {
|
||||||
|
return `${serverId}:${cwd.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
|
||||||
|
export function buildProjectNameByCwd(
|
||||||
|
targets: readonly ScheduleProjectTarget[],
|
||||||
|
): Map<string, string> {
|
||||||
|
const byCwd = new Map<string, string>();
|
||||||
|
for (const target of targets) {
|
||||||
|
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
|
||||||
|
}
|
||||||
|
return byCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name a stored cwd for display: the matching project name when the client
|
||||||
|
* knows this root on this host, otherwise the shortened path itself. Never
|
||||||
|
* blank, never a claim the client cannot back up.
|
||||||
|
*/
|
||||||
|
export function describeScheduleCwd(input: {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
projectNameByCwd: ReadonlyMap<string, string>;
|
||||||
|
}): string {
|
||||||
|
return (
|
||||||
|
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,40 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
type ReactElement,
|
||||||
|
} from "react";
|
||||||
import { ScrollView, Text, View } from "react-native";
|
import { ScrollView, Text, View } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { Plus } from "lucide-react-native";
|
import { Plus } from "lucide-react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
|
import { HostFilter } from "@/components/hosts/host-filter";
|
||||||
|
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||||
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
||||||
import { SchedulesTable } from "@/components/schedules/schedules-table";
|
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
|
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||||
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
|
import {
|
||||||
|
useSchedules,
|
||||||
|
type AggregatedSchedule,
|
||||||
|
type ScheduleHostError,
|
||||||
|
} from "@/hooks/use-schedules";
|
||||||
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
|
import {
|
||||||
|
resolveSchedule,
|
||||||
|
type ScheduleBucket,
|
||||||
|
type ScheduleTargetAgent,
|
||||||
|
} from "@/schedules/schedule-derivation";
|
||||||
|
import {
|
||||||
|
buildProjectNameByCwd,
|
||||||
|
buildScheduleProjectTargets,
|
||||||
|
} from "@/schedules/schedule-project-targets";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
type FormState =
|
type FormState =
|
||||||
@@ -16,6 +42,11 @@ type FormState =
|
|||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
|
||||||
|
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
|
||||||
|
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
|
||||||
|
];
|
||||||
|
|
||||||
export function SchedulesScreen(): ReactElement {
|
export function SchedulesScreen(): ReactElement {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
@@ -27,38 +58,120 @@ export function SchedulesScreen(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SchedulesScreenContent(): ReactElement {
|
function SchedulesScreenContent(): ReactElement {
|
||||||
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
|
||||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||||
|
const { projects } = useProjects();
|
||||||
const openCreate = useCallback(() => {
|
const hosts = useHosts();
|
||||||
setForm({ mode: "create" });
|
const runtime = getHostRuntimeStore();
|
||||||
}, []);
|
const runtimeVersion = useSyncExternalStore(
|
||||||
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
|
() => runtime.getVersion(),
|
||||||
setForm({ mode: "edit", serverId, schedule });
|
() => runtime.getVersion(),
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeForm = useCallback(() => {
|
|
||||||
setForm({ mode: "closed" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const headerAction = useMemo(
|
|
||||||
() => (
|
|
||||||
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
|
|
||||||
New schedule
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
[openCreate],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-host agent-directory readiness from the runtime, not the aggregate agent
|
||||||
|
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
|
||||||
|
// agents, so a still-loading host would falsely mark its agent-target
|
||||||
|
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
|
||||||
|
// host's directory has loaded at least once.
|
||||||
|
const agentDirReadyHosts = useMemo(() => {
|
||||||
|
void runtimeVersion;
|
||||||
|
const ready = new Set<string>();
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
|
||||||
|
ready.add(host.serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
}, [hosts, runtime, runtimeVersion]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||||
|
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedHost !== ALL_HOSTS_OPTION_ID &&
|
||||||
|
!hosts.some((host) => host.serverId === selectedHost)
|
||||||
|
) {
|
||||||
|
setSelectedHost(ALL_HOSTS_OPTION_ID);
|
||||||
|
}
|
||||||
|
}, [hosts, selectedHost]);
|
||||||
|
|
||||||
|
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
||||||
|
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
||||||
|
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
||||||
|
}, []);
|
||||||
|
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
||||||
|
|
||||||
|
const agentsByKey = useMemo(() => {
|
||||||
|
const map = new Map<string, ScheduleTargetAgent>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const projectNameByCwd = useMemo(
|
||||||
|
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve every schedule's derived state and target line once, then partition
|
||||||
|
// by the host and status filters. Sorted newest-first for a stable order
|
||||||
|
// across hosts.
|
||||||
|
const resolvedRows = useMemo(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
return schedules.map((schedule) => ({
|
||||||
|
schedule,
|
||||||
|
resolved: resolveSchedule({
|
||||||
|
schedule,
|
||||||
|
serverId: schedule.serverId,
|
||||||
|
now,
|
||||||
|
agentsByKey,
|
||||||
|
projectNameByCwd,
|
||||||
|
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
|
||||||
|
|
||||||
|
const visibleRows = useMemo<ScheduleRowView[]>(() => {
|
||||||
|
const singleHost = hosts.length <= 1;
|
||||||
|
return resolvedRows
|
||||||
|
.filter(
|
||||||
|
({ schedule, resolved }) =>
|
||||||
|
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
|
||||||
|
resolved.bucket === statusFilter,
|
||||||
|
)
|
||||||
|
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
|
||||||
|
.map(({ schedule, resolved }) => ({
|
||||||
|
schedule,
|
||||||
|
targetLabel: resolved.target.label,
|
||||||
|
provider: resolved.target.provider,
|
||||||
|
state: resolved.state,
|
||||||
|
serverName: schedule.serverName,
|
||||||
|
singleHost,
|
||||||
|
}));
|
||||||
|
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
|
||||||
|
|
||||||
|
const showLoadError = isError && schedules.length === 0;
|
||||||
|
const showHostFilter = hosts.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
<MenuHeader title="Schedules" />
|
||||||
<SchedulesBody
|
<SchedulesScreenBody
|
||||||
sections={sections}
|
rows={visibleRows}
|
||||||
isLoading={isLoading}
|
hostErrors={hostErrors}
|
||||||
isError={isError}
|
hasSchedules={schedules.length > 0}
|
||||||
error={error}
|
isInitialLoad={isInitialLoad}
|
||||||
|
showLoadError={showLoadError}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
showHostFilter={showHostFilter}
|
||||||
|
hosts={hosts}
|
||||||
|
selectedHost={selectedHost}
|
||||||
|
onSelectHost={setSelectedHost}
|
||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
onCreate={openCreate}
|
onCreate={openCreate}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
@@ -74,24 +187,38 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SchedulesBody({
|
function SchedulesScreenBody({
|
||||||
sections,
|
rows,
|
||||||
isLoading,
|
hostErrors,
|
||||||
isError,
|
hasSchedules,
|
||||||
error,
|
isInitialLoad,
|
||||||
|
showLoadError,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
showHostFilter,
|
||||||
|
hosts,
|
||||||
|
selectedHost,
|
||||||
|
onSelectHost,
|
||||||
onRetry,
|
onRetry,
|
||||||
onCreate,
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
sections: ScheduleHostSection[];
|
rows: ScheduleRowView[];
|
||||||
isLoading: boolean;
|
hostErrors: ScheduleHostError[];
|
||||||
isError: boolean;
|
hasSchedules: boolean;
|
||||||
error: Error | null;
|
isInitialLoad: boolean;
|
||||||
|
showLoadError: boolean;
|
||||||
|
statusFilter: ScheduleBucket;
|
||||||
|
onStatusFilterChange: (value: ScheduleBucket) => void;
|
||||||
|
showHostFilter: boolean;
|
||||||
|
hosts: ReturnType<typeof useHosts>;
|
||||||
|
selectedHost: string;
|
||||||
|
onSelectHost: (serverId: string) => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
onEdit: (schedule: AggregatedSchedule) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
if (isLoading) {
|
if (isInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||||
@@ -99,76 +226,85 @@ function SchedulesBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (showLoadError) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
<Text style={styles.message}>Unable to load schedules</Text>
|
||||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||||
Retry
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sections.length === 0) {
|
if (!hasSchedules) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered} testID="schedules-empty">
|
||||||
|
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||||
<Text style={styles.message}>No schedules yet</Text>
|
<Text style={styles.message}>No schedules yet</Text>
|
||||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||||
|
Create a schedule
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.filterRow}>
|
||||||
|
<View style={styles.filterRowControls}>
|
||||||
|
{showHostFilter ? (
|
||||||
|
<HostFilter
|
||||||
|
hosts={hosts}
|
||||||
|
selectedHost={selectedHost}
|
||||||
|
onSelectHost={onSelectHost}
|
||||||
|
triggerTestID="schedules-host-filter-trigger"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={onStatusFilterChange}
|
||||||
|
options={STATUS_FILTER_OPTIONS}
|
||||||
|
testID="schedules-status-filter"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
|
||||||
New schedule
|
New schedule
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
<ScrollView
|
||||||
}
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
return (
|
showsVerticalScrollIndicator={false}
|
||||||
<ScrollView
|
keyboardShouldPersistTaps="handled"
|
||||||
style={styles.scroll}
|
testID="schedules-list"
|
||||||
contentContainerStyle={styles.scrollContent}
|
>
|
||||||
showsVerticalScrollIndicator={false}
|
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||||
keyboardShouldPersistTaps="handled"
|
{rows.length > 0 ? (
|
||||||
testID="schedules-sections"
|
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
|
||||||
>
|
) : (
|
||||||
{sections.map((section) => (
|
<View style={styles.filterEmpty}>
|
||||||
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
|
||||||
))}
|
</View>
|
||||||
</ScrollView>
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleHostSectionView({
|
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
|
||||||
section,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
section: ScheduleHostSection;
|
|
||||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const handleEdit = useCallback(
|
|
||||||
(schedule: ScheduleSummary) => {
|
|
||||||
onEdit(section.serverId, schedule);
|
|
||||||
},
|
|
||||||
[onEdit, section.serverId],
|
|
||||||
);
|
|
||||||
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
<View style={styles.errorsBannerWrap}>
|
||||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
<View style={styles.errorsBanner} testID="schedules-host-errors">
|
||||||
{section.serverName}
|
{errors.map((error) => (
|
||||||
</Text>
|
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
{`${error.serverName}: Could not load schedules`}
|
||||||
{section.schedules.length > 0 ? (
|
</Text>
|
||||||
<SchedulesTable
|
))}
|
||||||
serverId={section.serverId}
|
</View>
|
||||||
schedules={section.schedules}
|
|
||||||
onEditSchedule={handleEdit}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{section.schedules.length === 0 && !section.error ? (
|
|
||||||
<View style={styles.sectionEmpty}>
|
|
||||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -178,6 +314,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
centered: {
|
centered: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -185,43 +325,50 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[6],
|
gap: theme.spacing[6],
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
},
|
},
|
||||||
|
filterRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
filterRowControls: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
flexShrink: 1,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
scroll: {
|
scroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[3],
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
paddingBottom: theme.spacing[6],
|
paddingBottom: theme.spacing[6],
|
||||||
},
|
},
|
||||||
section: {
|
errorsBannerWrap: {
|
||||||
gap: theme.spacing[2],
|
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
errorsBanner: {
|
||||||
width: "100%",
|
borderWidth: 1,
|
||||||
maxWidth: 720,
|
borderColor: theme.colors.border,
|
||||||
alignSelf: "center",
|
borderRadius: theme.borderRadius.lg,
|
||||||
paddingHorizontal: theme.spacing[4],
|
padding: theme.spacing[3],
|
||||||
color: theme.colors.foregroundMuted,
|
gap: theme.spacing[1],
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
},
|
||||||
sectionError: {
|
errorsBannerText: {
|
||||||
width: "100%",
|
color: theme.colors.palette.red[300],
|
||||||
maxWidth: 720,
|
fontSize: theme.fontSize.xs,
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
},
|
||||||
sectionEmpty: {
|
filterEmpty: {
|
||||||
width: "100%",
|
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||||
maxWidth: 720,
|
paddingVertical: theme.spacing[6],
|
||||||
alignSelf: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
},
|
},
|
||||||
sectionEmptyText: {
|
filterEmptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||||
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
|
import { ChevronLeft } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { AgentList } from "@/components/agent-list";
|
import { AgentList } from "@/components/agent-list";
|
||||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
import { HostFilter } from "@/components/hosts/host-filter";
|
||||||
import {
|
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||||
ALL_HOSTS_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
|
||||||
HostPicker,
|
|
||||||
} from "@/components/hosts/host-picker";
|
|
||||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { type HostProfile } from "@/types/host-connection";
|
|
||||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export function SessionsScreen() {
|
export function SessionsScreen() {
|
||||||
@@ -30,71 +25,6 @@ export function SessionsScreen() {
|
|||||||
return <SessionsScreenContent />;
|
return <SessionsScreenContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionsHostFilter({
|
|
||||||
hosts,
|
|
||||||
selectedHost,
|
|
||||||
onSelectHost,
|
|
||||||
}: {
|
|
||||||
hosts: HostProfile[];
|
|
||||||
selectedHost: string;
|
|
||||||
onSelectHost: (serverId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
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="sessions-host-filter-trigger"
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
|
||||||
>
|
|
||||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
|
||||||
<Server size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<HostStatusDotSlot serverId={selectedHost} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
|
||||||
{selectedHostLabel}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</HostPicker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionsScreenContent() {
|
function SessionsScreenContent() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
|
|||||||
<MenuHeader title={t("sessions.title")} />
|
<MenuHeader title={t("sessions.title")} />
|
||||||
{showHostFilter ? (
|
{showHostFilter ? (
|
||||||
<View style={styles.filterContainer}>
|
<View style={styles.filterContainer}>
|
||||||
<SessionsHostFilter
|
<HostFilter
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
selectedHost={selectedHost}
|
selectedHost={selectedHost}
|
||||||
onSelectHost={setSelectedHost}
|
onSelectHost={setSelectedHost}
|
||||||
|
triggerTestID="sessions-host-filter-trigger"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
},
|
},
|
||||||
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,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
|||||||
|
|
||||||
export const BROWSER_TOOLS_TITLE = "Browser tools";
|
export const BROWSER_TOOLS_TITLE = "Browser tools";
|
||||||
export const BROWSER_TOOLS_WARNING =
|
export const BROWSER_TOOLS_WARNING =
|
||||||
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
|
"Allow agents to access and control Paseo browser tabs, including logged-in browser state. Only enable this for agents you trust.";
|
||||||
|
|
||||||
export interface BrowserToolsCardState {
|
export interface BrowserToolsCardState {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +23,14 @@ interface BrowserStoreState extends BrowserIndexState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserId(): string {
|
function createBrowserId(): string {
|
||||||
|
let browserId: string;
|
||||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||||
return globalThis.crypto.randomUUID();
|
browserId = globalThis.crypto.randomUUID();
|
||||||
|
} else {
|
||||||
|
const randomSuffix = Math.random().toString(16).slice(2) || "0";
|
||||||
|
browserId = `${Date.now()}-${randomSuffix}`;
|
||||||
}
|
}
|
||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
return BrowserAutomationBrowserIdSchema.parse(browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBrowserStore = create<BrowserStoreState>()(
|
export const useBrowserStore = create<BrowserStoreState>()(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
|
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
|
||||||
const result = buildSidebarProjectRowModel({
|
const result = buildSidebarProjectRowModel({
|
||||||
project: project({
|
project: project({
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
@@ -79,13 +79,49 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
kind: "project_section",
|
kind: "project_section",
|
||||||
chevron: "expand",
|
chevron: "expand",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_worktree",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("targets the project host, not route state, for new worktree actions", () => {
|
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
|
||||||
|
const result = buildSidebarProjectRowModel({
|
||||||
|
project: project({ projectKind: "directory", workspaces: [] }),
|
||||||
|
collapsed: false,
|
||||||
|
supportsMultiplicityByServerId: new Map([["srv", true]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.trailingAction).toEqual({
|
||||||
|
kind: "new_workspace",
|
||||||
|
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
|
||||||
|
const result = buildSidebarProjectRowModel({
|
||||||
|
project: project({ projectKind: "directory", workspaces: [] }),
|
||||||
|
collapsed: false,
|
||||||
|
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.trailingAction).toEqual({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
|
||||||
|
const result = buildSidebarProjectRowModel({
|
||||||
|
project: project({ projectKind: "git" }),
|
||||||
|
collapsed: false,
|
||||||
|
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.trailingAction).toEqual({
|
||||||
|
kind: "new_workspace",
|
||||||
|
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets the project host, not route state, for new workspace actions", () => {
|
||||||
const result = buildSidebarProjectRowModel({
|
const result = buildSidebarProjectRowModel({
|
||||||
project: project({
|
project: project({
|
||||||
hosts: [
|
hosts: [
|
||||||
@@ -98,13 +134,34 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_worktree",
|
kind: "new_workspace",
|
||||||
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
|
it("targets the first multiplicity-capable host for a non-git project", () => {
|
||||||
|
const result = buildSidebarProjectRowModel({
|
||||||
|
project: project({
|
||||||
|
projectKind: "directory",
|
||||||
|
hosts: [
|
||||||
|
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
|
||||||
|
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
collapsed: false,
|
||||||
|
supportsMultiplicityByServerId: new Map([["host-b", true]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
trailingAction: {
|
||||||
|
kind: "new_workspace",
|
||||||
|
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
|
||||||
const result = buildSidebarProjectRowModel({
|
const result = buildSidebarProjectRowModel({
|
||||||
project: project({
|
project: project({
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
@@ -120,7 +177,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
kind: "project_section",
|
kind: "project_section",
|
||||||
chevron: "expand",
|
chevron: "expand",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_worktree",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -149,7 +206,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
kind: "project_section",
|
kind: "project_section",
|
||||||
chevron: "collapse",
|
chevron: "collapse",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_worktree",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarProjectTrailingAction =
|
export type SidebarProjectTrailingAction =
|
||||||
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
|
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
|
||||||
| { kind: "none" };
|
| { kind: "none" };
|
||||||
|
|
||||||
export interface SidebarProjectSectionRowModel {
|
export interface SidebarProjectSectionRowModel {
|
||||||
@@ -17,6 +17,8 @@ export interface SidebarProjectSectionRowModel {
|
|||||||
|
|
||||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||||
|
|
||||||
|
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||||
|
|
||||||
function hostTarget(input: {
|
function hostTarget(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
iconWorkingDir: string;
|
iconWorkingDir: string;
|
||||||
@@ -40,9 +42,18 @@ export function resolveSidebarProjectIconTarget(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
|
// A project can host a brand-new workspace on a host when that host can create a
|
||||||
|
// git worktree (git projects) OR the host supports running multiple independent
|
||||||
|
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
||||||
|
// directories add a second workspace. Mirrors the gate used by the global "New
|
||||||
|
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
|
||||||
|
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
|
||||||
|
function resolveNewWorkspaceTarget(
|
||||||
|
project: SidebarProjectEntry,
|
||||||
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||||
|
): SidebarProjectHostTarget | null {
|
||||||
for (const host of project.hosts) {
|
for (const host of project.hosts) {
|
||||||
if (!host.canCreateWorktree) {
|
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const target = hostTarget(host);
|
const target = hostTarget(host);
|
||||||
@@ -53,18 +64,25 @@ function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectH
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
|
function projectTrailingAction(
|
||||||
const target = resolveNewWorktreeTarget(project);
|
project: SidebarProjectEntry,
|
||||||
return target ? { kind: "new_worktree", target } : { kind: "none" };
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||||
|
): SidebarProjectTrailingAction {
|
||||||
|
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
|
||||||
|
return target ? { kind: "new_workspace", target } : { kind: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarProjectRowModel(input: {
|
export function buildSidebarProjectRowModel(input: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
|
||||||
}): SidebarProjectRowModel {
|
}): SidebarProjectRowModel {
|
||||||
return {
|
return {
|
||||||
kind: "project_section",
|
kind: "project_section",
|
||||||
chevron: input.collapsed ? "expand" : "collapse",
|
chevron: input.collapsed ? "expand" : "collapse",
|
||||||
trailingAction: projectTrailingAction(input.project),
|
trailingAction: projectTrailingAction(
|
||||||
|
input.project,
|
||||||
|
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.103",
|
"@getpaseo/client": "0.1.104-beta.4",
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/server": "0.1.103",
|
"@getpaseo/server": "0.1.104-beta.4",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
uid?: number;
|
uid?: number;
|
||||||
listen?: string;
|
listen?: string;
|
||||||
|
executablePath?: string;
|
||||||
desktopManaged?: boolean;
|
desktopManaged?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
|||||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||||
|
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
|
||||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import type { CommandOptions } from "../../output/index.js";
|
||||||
|
import { runStatusCommand } from "./status.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
|
||||||
|
findExecutable: vi.fn(async () => null),
|
||||||
|
getOrCreateServerId: vi.fn(() => "srv_test"),
|
||||||
|
loadConfig: vi.fn(() => ({
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
relayUseTls: false,
|
||||||
|
relayPublicUseTls: false,
|
||||||
|
})),
|
||||||
|
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
|
||||||
|
spawnProcess: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@getpaseo/server", () => ({
|
||||||
|
execCommand: mocks.execCommand,
|
||||||
|
findExecutable: mocks.findExecutable,
|
||||||
|
getOrCreateServerId: mocks.getOrCreateServerId,
|
||||||
|
loadConfig: mocks.loadConfig,
|
||||||
|
resolvePaseoHome: mocks.resolvePaseoHome,
|
||||||
|
spawnProcess: mocks.spawnProcess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
|
||||||
|
tempRoots.push(root);
|
||||||
|
const home = path.join(root, ".paseo");
|
||||||
|
await mkdir(home, { recursive: true });
|
||||||
|
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
try {
|
||||||
|
process.env.PATH = "";
|
||||||
|
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
|
||||||
|
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
|
||||||
|
} finally {
|
||||||
|
if (previousPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daemon status desktop management fields", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports executable identity without deriving desktop management in the CLI", async () => {
|
||||||
|
const executablePath =
|
||||||
|
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
|
||||||
|
const home = await createStatusHome({
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: "2026-07-06T00:00:00.000Z",
|
||||||
|
hostname: "dev-host",
|
||||||
|
uid: 501,
|
||||||
|
listen: "/tmp/paseo-status.sock",
|
||||||
|
executablePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||||
|
daemonExecutablePath: executablePath,
|
||||||
|
desktopManaged: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
|
||||||
|
const home = await createStatusHome({
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: "2026-07-06T00:00:00.000Z",
|
||||||
|
hostname: "dev-host",
|
||||||
|
uid: 501,
|
||||||
|
listen: "/tmp/paseo-status.sock",
|
||||||
|
desktopManaged: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||||
|
daemonExecutablePath: null,
|
||||||
|
desktopManaged: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ interface DaemonStatus {
|
|||||||
owner: string | null;
|
owner: string | null;
|
||||||
logPath: string;
|
logPath: string;
|
||||||
daemonNode: string;
|
daemonNode: string;
|
||||||
|
daemonExecutablePath: string | null;
|
||||||
cliNode: string;
|
cliNode: string;
|
||||||
cliVersion: string;
|
cliVersion: string;
|
||||||
daemonVersion: string | null;
|
daemonVersion: string | null;
|
||||||
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
|||||||
{ key: "Owner", value: status.owner ?? "-" },
|
{ key: "Owner", value: status.owner ?? "-" },
|
||||||
{ key: "Logs", value: status.logPath },
|
{ key: "Logs", value: status.logPath },
|
||||||
{ key: "Daemon Node", value: status.daemonNode },
|
{ key: "Daemon Node", value: status.daemonNode },
|
||||||
|
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
|
||||||
{ key: "CLI Node", value: status.cliNode },
|
{ key: "CLI Node", value: status.cliNode },
|
||||||
{ key: "CLI", value: status.cliVersion },
|
{ key: "CLI", value: status.cliVersion },
|
||||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||||
@@ -405,9 +407,11 @@ export async function runStatusCommand(
|
|||||||
owner,
|
owner,
|
||||||
logPath: state.logPath,
|
logPath: state.logPath,
|
||||||
daemonNode,
|
daemonNode,
|
||||||
|
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
|
||||||
cliNode,
|
cliNode,
|
||||||
cliVersion,
|
cliVersion,
|
||||||
daemonVersion,
|
daemonVersion,
|
||||||
|
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||||
providers,
|
providers,
|
||||||
note,
|
note,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "node ../../scripts/clean-package-dist.mjs",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"build": "tsc -p tsconfig.json --incremental false",
|
"build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false",
|
||||||
"build:clean": "npm run clean && npm run build",
|
"build:clean": "npm run clean && npm run build",
|
||||||
"prepack": "npm run build:clean",
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/relay": "0.1.103",
|
"@getpaseo/relay": "0.1.104-beta.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
|
import type {
|
||||||
|
GetProvidersSnapshotResponseMessage,
|
||||||
|
ListProviderModelsResponseMessage,
|
||||||
|
SessionOutboundMessage,
|
||||||
|
} from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
|
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||||
|
type ProvidersSnapshotUpdatePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "providers_snapshot_update" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
|
// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104
|
||||||
|
function normalizeAgentModels(
|
||||||
|
models: AgentModelDefinition[] | undefined,
|
||||||
|
): AgentModelDefinition[] | undefined {
|
||||||
|
if (!models) {
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const normalized = models.map((model) => {
|
||||||
|
const next = normalizeAgentModelDefinition(model);
|
||||||
|
changed ||= next !== model;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? normalized : models;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry {
|
||||||
|
const models = normalizeAgentModels(entry.models);
|
||||||
|
return models === entry.models ? entry : { ...entry, models };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderSnapshotEntries(
|
||||||
|
entries: ProviderSnapshotEntry[],
|
||||||
|
): ProviderSnapshotEntry[] {
|
||||||
|
let changed = false;
|
||||||
|
const normalized = entries.map((entry) => {
|
||||||
|
const next = normalizeProviderSnapshotEntry(entry);
|
||||||
|
changed ||= next !== entry;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? normalized : entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeListProviderModelsPayload(
|
||||||
|
payload: ListProviderModelsPayload,
|
||||||
|
): ListProviderModelsPayload {
|
||||||
|
const models = normalizeAgentModels(payload.models);
|
||||||
|
return models === payload.models ? payload : { ...payload, models };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProvidersSnapshotPayload<
|
||||||
|
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
|
||||||
|
>(payload: T): T {
|
||||||
|
const entries = normalizeProviderSnapshotEntries(payload.entries);
|
||||||
|
return entries === payload.entries ? payload : { ...payload, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProviderSnapshotUpdateMessage(
|
||||||
|
msg: SessionOutboundMessage,
|
||||||
|
): SessionOutboundMessage {
|
||||||
|
if (msg.type !== "providers_snapshot_update") {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
|
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
|
||||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||||
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import {
|
import {
|
||||||
decodeFileTransferFrame,
|
decodeFileTransferFrame,
|
||||||
encodeFileTransferFrame,
|
encodeFileTransferFrame,
|
||||||
@@ -164,7 +165,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy
|
|||||||
capabilities: z.record(z.unknown()),
|
capabilities: z.record(z.unknown()),
|
||||||
})
|
})
|
||||||
.parse(JSON.parse(assertStr(mock.sent[0])));
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
||||||
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBeUndefined();
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("advertises consumer-provided browser automation capabilities", async () => {
|
test("advertises consumer-provided browser automation capabilities", async () => {
|
||||||
@@ -174,7 +175,12 @@ test("advertises consumer-provided browser automation capabilities", async () =>
|
|||||||
clientId: "browser_capability_unit_test",
|
clientId: "browser_capability_unit_test",
|
||||||
transportFactory: () => mock.transport,
|
transportFactory: () => mock.transport,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
capabilities: { [CLIENT_CAPS.desktopBrowserAutomation]: true },
|
capabilities: {
|
||||||
|
[CLIENT_CAPS.browserHost]: {
|
||||||
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||||
|
hostKind: "desktop app",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
|
||||||
@@ -188,7 +194,10 @@ test("advertises consumer-provided browser automation capabilities", async () =>
|
|||||||
capabilities: z.record(z.unknown()),
|
capabilities: z.record(z.unknown()),
|
||||||
})
|
})
|
||||||
.parse(JSON.parse(assertStr(mock.sent[0])));
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
||||||
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
|
||||||
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||||
|
hostKind: "desktop app",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const noopLogger: Logger = {
|
const noopLogger: Logger = {
|
||||||
@@ -506,7 +515,12 @@ test("advertises client capabilities in hello", async () => {
|
|||||||
logger,
|
logger,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
transportFactory: () => mock.transport,
|
transportFactory: () => mock.transport,
|
||||||
capabilities: { desktop_browser_automation: true },
|
capabilities: {
|
||||||
|
browser_host: {
|
||||||
|
supportedCommands: ["list_tabs"],
|
||||||
|
hostKind: "desktop app",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
|
||||||
@@ -524,7 +538,10 @@ test("advertises client capabilities in hello", async () => {
|
|||||||
custom_mode_icons: true,
|
custom_mode_icons: true,
|
||||||
reasoning_merge_enum: true,
|
reasoning_merge_enum: true,
|
||||||
terminal_reflowable_snapshot: true,
|
terminal_reflowable_snapshot: true,
|
||||||
desktop_browser_automation: true,
|
browser_host: {
|
||||||
|
supportedCommands: ["list_tabs"],
|
||||||
|
hostKind: "desktop app",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
DaemonUpdateResponseSchema,
|
DaemonUpdateResponseSchema,
|
||||||
SessionInboundMessageSchema,
|
SessionInboundMessageSchema,
|
||||||
type ServerInfoStatusPayload,
|
type ServerInfoStatusPayload,
|
||||||
WSOutboundMessageSchema,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
|
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
|
||||||
import type {
|
import type {
|
||||||
AgentStreamEventPayload,
|
AgentStreamEventPayload,
|
||||||
AgentSnapshotPayload,
|
AgentSnapshotPayload,
|
||||||
@@ -118,6 +118,11 @@ import {
|
|||||||
type WebSocketFactory,
|
type WebSocketFactory,
|
||||||
} from "./daemon-client-transport.js";
|
} from "./daemon-client-transport.js";
|
||||||
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
|
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
|
||||||
|
import {
|
||||||
|
normalizeListProviderModelsPayload,
|
||||||
|
normalizeProviderSnapshotUpdateMessage,
|
||||||
|
normalizeProvidersSnapshotPayload,
|
||||||
|
} from "./compat/normalize-provider-models.js";
|
||||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||||
import type {
|
import type {
|
||||||
BrowserAutomationExecuteRequest,
|
BrowserAutomationExecuteRequest,
|
||||||
@@ -252,7 +257,7 @@ export interface DaemonClientConfig {
|
|||||||
};
|
};
|
||||||
runtimeMetricsIntervalMs?: number;
|
runtimeMetricsIntervalMs?: number;
|
||||||
runtimeMetricsWindowMs?: number;
|
runtimeMetricsWindowMs?: number;
|
||||||
capabilities?: Partial<Record<ClientCapability, boolean>>;
|
capabilities?: Partial<Record<ClientCapability, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
@@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "write_project_config_response" }
|
{ type: "write_project_config_response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ListCommandsDraftConfig = Pick<
|
type ListCommandsDraftConfig = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -3728,7 +3734,7 @@ export class DaemonClient {
|
|||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
options?: { cwd?: string; requestId?: string },
|
options?: { cwd?: string; requestId?: string },
|
||||||
): Promise<ListProviderModelsPayload> {
|
): Promise<ListProviderModelsPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
requestId: options?.requestId,
|
requestId: options?.requestId,
|
||||||
message: {
|
message: {
|
||||||
type: "list_provider_models_request",
|
type: "list_provider_models_request",
|
||||||
@@ -3739,6 +3745,7 @@ export class DaemonClient {
|
|||||||
// Provider SDK cold starts (especially model discovery) can exceed 60s.
|
// Provider SDK cold starts (especially model discovery) can exceed 60s.
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
});
|
});
|
||||||
|
return normalizeListProviderModelsPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listProviderModes(
|
async listProviderModes(
|
||||||
@@ -3788,7 +3795,7 @@ export class DaemonClient {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}): Promise<GetProvidersSnapshotPayload> {
|
}): Promise<GetProvidersSnapshotPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
requestId: options?.requestId,
|
requestId: options?.requestId,
|
||||||
message: {
|
message: {
|
||||||
type: "get_providers_snapshot_request",
|
type: "get_providers_snapshot_request",
|
||||||
@@ -3796,6 +3803,7 @@ export class DaemonClient {
|
|||||||
},
|
},
|
||||||
responseType: "get_providers_snapshot_response",
|
responseType: "get_providers_snapshot_response",
|
||||||
});
|
});
|
||||||
|
return normalizeProvidersSnapshotPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDaemonConfig(
|
async getDaemonConfig(
|
||||||
@@ -4754,7 +4762,7 @@ export class DaemonClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
|
const parsed = validateWSOutboundMessage(parsedJson);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const msgType =
|
const msgType =
|
||||||
parsedJson != null &&
|
parsedJson != null &&
|
||||||
@@ -5017,8 +5025,10 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
||||||
if (msg.type === "status") {
|
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
|
||||||
const serverInfo = parseServerInfoStatusPayload(msg.payload);
|
|
||||||
|
if (consumerMessage.type === "status") {
|
||||||
|
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
|
||||||
if (serverInfo) {
|
if (serverInfo) {
|
||||||
this.lastServerInfoMessage = serverInfo;
|
this.lastServerInfoMessage = serverInfo;
|
||||||
if (this.connectionState.status === "connecting") {
|
if (this.connectionState.status === "connecting") {
|
||||||
@@ -5034,39 +5044,39 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "terminal_stream_exit") {
|
if (consumerMessage.type === "terminal_stream_exit") {
|
||||||
this.terminalStreams.removeTerminal(msg.payload.terminalId);
|
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rawMessageListeners.size > 0) {
|
if (this.rawMessageListeners.size > 0) {
|
||||||
for (const handler of this.rawMessageListeners) {
|
for (const handler of this.rawMessageListeners) {
|
||||||
try {
|
try {
|
||||||
handler(msg);
|
handler(consumerMessage);
|
||||||
} catch {
|
} catch {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = this.messageHandlers.get(msg.type);
|
const handlers = this.messageHandlers.get(consumerMessage.type);
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
for (const handler of handlers) {
|
for (const handler of handlers) {
|
||||||
try {
|
try {
|
||||||
handler(msg);
|
handler(consumerMessage);
|
||||||
} catch {
|
} catch {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = this.toEvent(msg);
|
const event = this.toEvent(consumerMessage);
|
||||||
if (event) {
|
if (event) {
|
||||||
for (const handler of this.eventListeners) {
|
for (const handler of this.eventListeners) {
|
||||||
handler(event);
|
handler(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resolveWaiters(msg);
|
this.resolveWaiters(consumerMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveWaiters(msg: SessionOutboundMessage): void {
|
private resolveWaiters(msg: SessionOutboundMessage): void {
|
||||||
|
|||||||
@@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snapshotUpdates: string[] = [];
|
const snapshotUpdates: string[] = [];
|
||||||
|
const snapshotModelDefaults: Array<string | undefined> = [];
|
||||||
const unsubscribe = client.providers.subscribe((update) => {
|
const unsubscribe = client.providers.subscribe((update) => {
|
||||||
snapshotUpdates.push(update.generatedAt);
|
snapshotUpdates.push(update.generatedAt);
|
||||||
|
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
|
||||||
});
|
});
|
||||||
ws.message(
|
ws.message(
|
||||||
sessionMessage({
|
sessionMessage({
|
||||||
type: "providers_snapshot_update",
|
type: "providers_snapshot_update",
|
||||||
payload: {
|
payload: {
|
||||||
cwd: "/repo/sdk",
|
cwd: "/repo/sdk",
|
||||||
entries: [{ provider: "codex", status: "ready", enabled: true }],
|
entries: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.5",
|
||||||
|
label: "GPT-5.5",
|
||||||
|
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
generatedAt: "2026-05-16T01:00:00.000Z",
|
generatedAt: "2026-05-16T01:00:00.000Z",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
|
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
|
||||||
|
expect(snapshotModelDefaults).toEqual(["high"]);
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
await client.close();
|
await client.close();
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
out/
|
||||||
|
fatal-error.txt
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Paseo Capture Harness Target</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 1280px;
|
||||||
|
min-height: 1600px;
|
||||||
|
margin: 0;
|
||||||
|
background: #ff00ff;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
top: 48px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow:
|
||||||
|
5px 5px 0 #000000,
|
||||||
|
-2px -2px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
position: absolute;
|
||||||
|
left: 54px;
|
||||||
|
top: 150px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
position: absolute;
|
||||||
|
left: 56px;
|
||||||
|
top: 220px;
|
||||||
|
width: 360px;
|
||||||
|
height: 92px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#000000 0 25%,
|
||||||
|
#ffffff 25% 50%,
|
||||||
|
#000000 50% 75%,
|
||||||
|
#ffffff 75% 100%
|
||||||
|
);
|
||||||
|
border: 8px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
top: 1370px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 60px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-shadow: 5px 5px 0 #000000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="label">PASEO CAPTURE HARNESS</div>
|
||||||
|
<div class="sub">LOCAL MAGENTA TARGET 1280x800 VIEWPORT</div>
|
||||||
|
<div class="block"></div>
|
||||||
|
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const label = params.get("label");
|
||||||
|
const sub = params.get("sub");
|
||||||
|
const bottom = params.get("bottom");
|
||||||
|
if (label) {
|
||||||
|
document.querySelector(".label").textContent = label;
|
||||||
|
}
|
||||||
|
if (sub) {
|
||||||
|
document.querySelector(".sub").textContent = sub;
|
||||||
|
}
|
||||||
|
if (bottom) {
|
||||||
|
document.querySelector(".bottom").textContent = bottom;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Paseo Delayed Capture Harness Target</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 1280px;
|
||||||
|
min-height: 1600px;
|
||||||
|
margin: 0;
|
||||||
|
background: #202020;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ready {
|
||||||
|
background: #ff00ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ready .content {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
top: 48px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow:
|
||||||
|
5px 5px 0 #000000,
|
||||||
|
-2px -2px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
position: absolute;
|
||||||
|
left: 54px;
|
||||||
|
top: 150px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
position: absolute;
|
||||||
|
left: 56px;
|
||||||
|
top: 220px;
|
||||||
|
width: 360px;
|
||||||
|
height: 92px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#000000 0 25%,
|
||||||
|
#ffffff 25% 50%,
|
||||||
|
#000000 50% 75%,
|
||||||
|
#ffffff 75% 100%
|
||||||
|
);
|
||||||
|
border: 8px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
top: 1370px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 60px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-shadow: 5px 5px 0 #000000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add("ready");
|
||||||
|
}, 1500);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="content">
|
||||||
|
<div class="label">PASEO CAPTURE HARNESS</div>
|
||||||
|
<div class="sub">DELAYED FIRST PAINT TARGET</div>
|
||||||
|
<div class="block"></div>
|
||||||
|
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Paseo Browser Capture Harness</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: #202020;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#paseo-browser-resident-webviews {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-harness-webview {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1280px;
|
||||||
|
height: 800px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="paseo-browser-resident-webviews" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||||
|
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||||
|
const host = document.getElementById("paseo-browser-resident-webviews");
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const requestedWebviewCount = params.has("webviewCount")
|
||||||
|
? Number(params.get("webviewCount"))
|
||||||
|
: 2;
|
||||||
|
const webviewCount = Number.isFinite(requestedWebviewCount)
|
||||||
|
? Math.max(0, requestedWebviewCount)
|
||||||
|
: 2;
|
||||||
|
const webviews = [];
|
||||||
|
const activeTokens = new Set();
|
||||||
|
let nextTokenId = 0;
|
||||||
|
let permanentParkingState = params.get("permanentParkingState") || "";
|
||||||
|
|
||||||
|
function appendHarnessWebview(sourceUrl) {
|
||||||
|
const index = webviews.length;
|
||||||
|
const webview = document.createElement("webview");
|
||||||
|
webview.id = `target-webview-${index + 1}`;
|
||||||
|
webview.className = "capture-harness-webview";
|
||||||
|
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
|
||||||
|
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
|
||||||
|
webview.setAttribute("allowpopups", "true");
|
||||||
|
webview.setAttribute("spellcheck", "false");
|
||||||
|
webview.setAttribute("autosize", "on");
|
||||||
|
webview.src = sourceUrl;
|
||||||
|
applyStackedWebviewStyle(webview);
|
||||||
|
host.appendChild(webview);
|
||||||
|
webviews.push(webview);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < webviewCount; index += 1) {
|
||||||
|
appendHarnessWebview(params.get("targetUrl") || "bright.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHostParking() {
|
||||||
|
host.style.position = "fixed";
|
||||||
|
host.style.left = "-20000px";
|
||||||
|
host.style.top = "0";
|
||||||
|
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||||
|
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||||
|
host.style.overflow = "hidden";
|
||||||
|
host.style.opacity = "0";
|
||||||
|
host.style.pointerEvents = "none";
|
||||||
|
host.style.zIndex = "";
|
||||||
|
host.style.clipPath = "";
|
||||||
|
host.style.visibility = "";
|
||||||
|
host.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWebviewBaseStyle(webview) {
|
||||||
|
webview.style.display = "inline-flex";
|
||||||
|
webview.style.flex = "0 0 auto";
|
||||||
|
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||||
|
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||||
|
webview.style.border = "0";
|
||||||
|
webview.style.background = "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLegacyVerticalWebviewStyle(webview, index) {
|
||||||
|
applyWebviewBaseStyle(webview);
|
||||||
|
webview.style.position = "";
|
||||||
|
webview.style.left = "";
|
||||||
|
webview.style.top = "";
|
||||||
|
webview.style.marginTop = index === 0 ? "0" : "4px";
|
||||||
|
webview.style.zIndex = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStackedWebviewStyle(webview, zIndex = "0") {
|
||||||
|
applyWebviewBaseStyle(webview);
|
||||||
|
webview.style.position = "absolute";
|
||||||
|
webview.style.left = "0";
|
||||||
|
webview.style.top = "0";
|
||||||
|
webview.style.marginTop = "0";
|
||||||
|
webview.style.zIndex = zIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyParking() {
|
||||||
|
permanentParkingState = "";
|
||||||
|
applyHostParking();
|
||||||
|
webviews.forEach((webview) => {
|
||||||
|
applyStackedWebviewStyle(webview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLegacyVerticalParking() {
|
||||||
|
applyHostParking();
|
||||||
|
webviews.forEach((webview, index) => {
|
||||||
|
applyLegacyVerticalWebviewStyle(webview, index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCapturePrep() {
|
||||||
|
permanentParkingState = "";
|
||||||
|
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.zIndex = "1";
|
||||||
|
host.style.clipPath = "";
|
||||||
|
host.style.visibility = "";
|
||||||
|
host.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHostPermanentBase() {
|
||||||
|
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 = "";
|
||||||
|
host.style.transformOrigin = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermanentWebviewBase(webview, index) {
|
||||||
|
applyStackedWebviewStyle(webview);
|
||||||
|
webview.style.opacity = "";
|
||||||
|
webview.style.transform = "";
|
||||||
|
webview.style.pointerEvents = "";
|
||||||
|
webview.style.zIndex = "0";
|
||||||
|
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||||
|
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||||
|
webview.style.left = "0";
|
||||||
|
webview.style.top = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermanentParkingState(stateName) {
|
||||||
|
permanentParkingState = stateName;
|
||||||
|
activeTokens.clear();
|
||||||
|
applyHostPermanentBase();
|
||||||
|
webviews.forEach(applyPermanentWebviewBase);
|
||||||
|
|
||||||
|
if (stateName === "p1-overflow-1x1") {
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p2-clip-path-1x1") {
|
||||||
|
host.style.width = "1px";
|
||||||
|
host.style.height = "1px";
|
||||||
|
host.style.overflow = "visible";
|
||||||
|
host.style.clipPath = "inset(1px)";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p3-opacity-0") {
|
||||||
|
host.style.opacity = "0";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p4-transform-scale-0") {
|
||||||
|
host.style.transform = "scale(0)";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p4-transform-scale-0001") {
|
||||||
|
host.style.transform = "scale(0.001)";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p5-webview-0x0") {
|
||||||
|
webviews.forEach((webview) => {
|
||||||
|
webview.style.width = "0";
|
||||||
|
webview.style.height = "0";
|
||||||
|
});
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p5-webview-1x1") {
|
||||||
|
webviews.forEach((webview) => {
|
||||||
|
webview.style.width = "1px";
|
||||||
|
webview.style.height = "1px";
|
||||||
|
});
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p6-z-index-negative") {
|
||||||
|
host.style.zIndex = "-1";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName === "p7-opacity-001") {
|
||||||
|
host.style.opacity = "0.01";
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown permanent parking state: ${stateName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTargetStacking(targetIndex) {
|
||||||
|
webviews.forEach((webview, index) => {
|
||||||
|
applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFrames(count) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
function step(remaining) {
|
||||||
|
if (remaining <= 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => step(remaining - 1));
|
||||||
|
}
|
||||||
|
step(count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(targetIndex = 0) {
|
||||||
|
const hostRect = host.getBoundingClientRect();
|
||||||
|
const targetWebview = webviews[targetIndex] || webviews[0];
|
||||||
|
const webviewRect = targetWebview?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
hostStyle: {
|
||||||
|
left: host.style.left,
|
||||||
|
top: host.style.top,
|
||||||
|
width: host.style.width,
|
||||||
|
height: host.style.height,
|
||||||
|
overflow: host.style.overflow,
|
||||||
|
opacity: host.style.opacity,
|
||||||
|
pointerEvents: host.style.pointerEvents,
|
||||||
|
zIndex: host.style.zIndex,
|
||||||
|
clipPath: host.style.clipPath,
|
||||||
|
transform: host.style.transform,
|
||||||
|
},
|
||||||
|
hostRect: {
|
||||||
|
x: hostRect.x,
|
||||||
|
y: hostRect.y,
|
||||||
|
width: hostRect.width,
|
||||||
|
height: hostRect.height,
|
||||||
|
},
|
||||||
|
webviewRect: {
|
||||||
|
x: webviewRect?.x ?? 0,
|
||||||
|
y: webviewRect?.y ?? 0,
|
||||||
|
width: webviewRect?.width ?? 0,
|
||||||
|
height: webviewRect?.height ?? 0,
|
||||||
|
},
|
||||||
|
webviews: webviews.map((webview) => {
|
||||||
|
const rect = webview.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id: webview.id,
|
||||||
|
zIndex: webview.style.zIndex,
|
||||||
|
position: webview.style.position,
|
||||||
|
opacity: webview.style.opacity,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.captureHarness = {
|
||||||
|
async waitForFrames(count = 2) {
|
||||||
|
await waitFrames(count);
|
||||||
|
return state();
|
||||||
|
},
|
||||||
|
webContentsIds() {
|
||||||
|
return webviews.map((webview) =>
|
||||||
|
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
addWebview(sourceUrl) {
|
||||||
|
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
|
||||||
|
},
|
||||||
|
addPermanentWebview(sourceUrl, stateName) {
|
||||||
|
const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
|
||||||
|
applyPermanentParkingState(stateName || permanentParkingState);
|
||||||
|
return index;
|
||||||
|
},
|
||||||
|
async applyPermanentParkingState(stateName) {
|
||||||
|
const nextState = applyPermanentParkingState(stateName);
|
||||||
|
await waitFrames(2);
|
||||||
|
return nextState;
|
||||||
|
},
|
||||||
|
async prepareForPixelCapture(targetIndex = 0) {
|
||||||
|
const token = `capture-${++nextTokenId}`;
|
||||||
|
activeTokens.add(token);
|
||||||
|
applyCapturePrep();
|
||||||
|
applyTargetStacking(targetIndex);
|
||||||
|
await waitFrames(2);
|
||||||
|
webviews[targetIndex].getBoundingClientRect();
|
||||||
|
return { token, state: state(targetIndex) };
|
||||||
|
},
|
||||||
|
async prepareLegacyVerticalPixelCapture(targetIndex = 0) {
|
||||||
|
const token = `legacy-capture-${++nextTokenId}`;
|
||||||
|
activeTokens.add(token);
|
||||||
|
applyLegacyVerticalParking();
|
||||||
|
applyCapturePrep();
|
||||||
|
await waitFrames(2);
|
||||||
|
webviews[targetIndex].getBoundingClientRect();
|
||||||
|
return { token, state: state(targetIndex) };
|
||||||
|
},
|
||||||
|
async restorePixelCapture(token) {
|
||||||
|
activeTokens.delete(token);
|
||||||
|
if (activeTokens.size === 0) {
|
||||||
|
applyParking();
|
||||||
|
}
|
||||||
|
await waitFrames(2);
|
||||||
|
return state();
|
||||||
|
},
|
||||||
|
async restoreLegacyVerticalParking(token) {
|
||||||
|
activeTokens.delete(token);
|
||||||
|
applyLegacyVerticalParking();
|
||||||
|
await waitFrames(2);
|
||||||
|
return state();
|
||||||
|
},
|
||||||
|
async restoreParking() {
|
||||||
|
activeTokens.clear();
|
||||||
|
applyParking();
|
||||||
|
await waitFrames(2);
|
||||||
|
return state();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (permanentParkingState) {
|
||||||
|
applyPermanentParkingState(permanentParkingState);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||||
|
|
||||||
|
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
|
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
|
||||||
"build:main": "tsc -p tsconfig.json",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
|
"capture-harness": "./capture-harness/run.sh",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:win": "powershell ./scripts/dev.ps1",
|
"dev:win": "powershell ./scripts/dev.ps1",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
||||||
|
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||||
|
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||||
settings: {
|
settings: {
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
daemon: {
|
daemon: {
|
||||||
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("electron", () => ({
|
vi.mock("electron", () => ({
|
||||||
app: {
|
app: {
|
||||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
getPath: vi.fn((name: string) =>
|
||||||
|
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
|
||||||
|
),
|
||||||
getVersion: vi.fn(() => "1.2.3"),
|
getVersion: vi.fn(() => "1.2.3"),
|
||||||
isPackaged: true,
|
isPackaged: true,
|
||||||
},
|
},
|
||||||
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pidLockPath(): string {
|
||||||
|
return `${mocks.paseoHome}/paseo.pid`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePidLock(input: {
|
||||||
|
pid: number;
|
||||||
|
executablePath?: string;
|
||||||
|
desktopManaged?: boolean;
|
||||||
|
}): void {
|
||||||
|
mkdirSync(mocks.paseoHome, { recursive: true });
|
||||||
|
writeFileSync(pidLockPath(), JSON.stringify(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeManagedPidLock(pid: number): void {
|
||||||
|
writePidLock({
|
||||||
|
pid,
|
||||||
|
executablePath: mocks.managedDaemonExecutablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePidLock(): void {
|
||||||
|
rmSync(pidLockPath(), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleFailedStartup(child: MockChildProcess): void {
|
function scheduleFailedStartup(child: MockChildProcess): void {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
child.emit("exit", 1, null);
|
child.emit("exit", 1, null);
|
||||||
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
|
|||||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives desktop management from the pid lock executable path", async () => {
|
||||||
|
writeManagedPidLock(4242);
|
||||||
|
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||||
|
localDaemon: "running",
|
||||||
|
connectedDaemon: "reachable",
|
||||||
|
serverId: "server-1",
|
||||||
|
pid: 4242,
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
daemonVersion: "1.2.3",
|
||||||
|
desktopManaged: false,
|
||||||
|
});
|
||||||
|
const handlers = createDaemonCommandHandlers();
|
||||||
|
|
||||||
|
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
status: "running",
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
hostname: null,
|
||||||
|
pid: 4242,
|
||||||
|
home: mocks.paseoHome,
|
||||||
|
version: "1.2.3",
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
|
||||||
|
writePidLock({ pid: 4242, desktopManaged: true });
|
||||||
|
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||||
|
localDaemon: "running",
|
||||||
|
connectedDaemon: "reachable",
|
||||||
|
serverId: "server-1",
|
||||||
|
pid: 4242,
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
daemonVersion: "1.2.3",
|
||||||
|
desktopManaged: false,
|
||||||
|
});
|
||||||
|
const handlers = createDaemonCommandHandlers();
|
||||||
|
|
||||||
|
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
|
||||||
|
status: "running",
|
||||||
|
pid: 4242,
|
||||||
|
desktopManaged: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
|
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
|
||||||
|
writeManagedPidLock(4242);
|
||||||
mocks.runExternalCliJsonCommand
|
mocks.runExternalCliJsonCommand
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "running",
|
localDaemon: "running",
|
||||||
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
|
|||||||
listen: "127.0.0.1:6767",
|
listen: "127.0.0.1:6767",
|
||||||
desktopManaged: true,
|
desktopManaged: true,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ action: "stopped" })
|
.mockImplementationOnce(async () => {
|
||||||
|
removePidLock();
|
||||||
|
return { action: "stopped" };
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stopped",
|
localDaemon: "stopped",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||||
|
writeManagedPidLock(7675);
|
||||||
mocks.runExternalCliJsonCommand
|
mocks.runExternalCliJsonCommand
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stale_pid",
|
localDaemon: "stale_pid",
|
||||||
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
|
|||||||
daemonVersion: "1.2.2",
|
daemonVersion: "1.2.2",
|
||||||
desktopManaged: true,
|
desktopManaged: true,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ action: "stopped" })
|
.mockImplementationOnce(async () => {
|
||||||
|
removePidLock();
|
||||||
|
return { action: "stopped" };
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stopped",
|
localDaemon: "stopped",
|
||||||
connectedDaemon: "unreachable",
|
connectedDaemon: "unreachable",
|
||||||
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("records the renderer stop reason when stopping the desktop daemon", async () => {
|
it("records the renderer stop reason when stopping the desktop daemon", async () => {
|
||||||
|
writeManagedPidLock(4242);
|
||||||
mocks.runExternalCliJsonCommand
|
mocks.runExternalCliJsonCommand
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "running",
|
localDaemon: "running",
|
||||||
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
|
|||||||
listen: "127.0.0.1:6767",
|
listen: "127.0.0.1:6767",
|
||||||
desktopManaged: true,
|
desktopManaged: true,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
|
.mockImplementationOnce(async () => {
|
||||||
|
removePidLock();
|
||||||
|
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stopped",
|
localDaemon: "stopped",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||||
|
writeManagedPidLock(7675);
|
||||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||||
localDaemon: "stale_pid",
|
localDaemon: "stale_pid",
|
||||||
connectedDaemon: "reachable",
|
connectedDaemon: "reachable",
|
||||||
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
||||||
|
writeManagedPidLock(7675);
|
||||||
mocks.runExternalCliJsonCommand
|
mocks.runExternalCliJsonCommand
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stale_pid",
|
localDaemon: "stale_pid",
|
||||||
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
|
|||||||
daemonVersion: "1.2.2",
|
daemonVersion: "1.2.2",
|
||||||
desktopManaged: true,
|
desktopManaged: true,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ action: "stopped" })
|
.mockImplementationOnce(async () => {
|
||||||
|
removePidLock();
|
||||||
|
return { action: "stopped" };
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
localDaemon: "stopped",
|
localDaemon: "stopped",
|
||||||
connectedDaemon: "unreachable",
|
connectedDaemon: "unreachable",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockImplementationOnce(async () => {
|
||||||
localDaemon: "running",
|
writeManagedPidLock(8888);
|
||||||
connectedDaemon: "reachable",
|
return {
|
||||||
serverId: "server-2",
|
localDaemon: "running",
|
||||||
pid: 8888,
|
connectedDaemon: "reachable",
|
||||||
listen: "127.0.0.1:6767",
|
serverId: "server-2",
|
||||||
hostname: "dev-host",
|
pid: 8888,
|
||||||
daemonVersion: "1.2.3",
|
listen: "127.0.0.1:6767",
|
||||||
desktopManaged: true,
|
hostname: "dev-host",
|
||||||
|
daemonVersion: "1.2.3",
|
||||||
|
desktopManaged: true,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
||||||
const handlers = createDaemonCommandHandlers();
|
const handlers = createDaemonCommandHandlers();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import type { DesktopSettings } from "../settings/desktop-settings.js";
|
import type { DesktopSettings } from "../settings/desktop-settings.js";
|
||||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||||
|
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
|
||||||
|
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||||
@@ -123,18 +124,43 @@ function logFilePath(): string {
|
|||||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDesktopManagedDaemonRunningSync(): boolean {
|
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
|
||||||
|
const executablePath = toTrimmedString(lock.executablePath);
|
||||||
|
if (executablePath) {
|
||||||
|
return deriveDesktopManagedFromExecutablePath({
|
||||||
|
daemonExecutablePath: executablePath,
|
||||||
|
desktopExecutablePath: app.getPath("exe"),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||||
|
return lock.desktopManaged === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPidLockRecord(): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
|
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
|
||||||
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
|
const lock = JSON.parse(raw) as unknown;
|
||||||
if (lock.desktopManaged !== true) return false;
|
return isRecord(lock) ? lock : null;
|
||||||
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
|
||||||
return isProcessRunning(lock.pid);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDesktopManagedFromPidLock(): boolean {
|
||||||
|
const lock = readPidLockRecord();
|
||||||
|
return lock !== null && deriveDesktopManagedFromPidLock(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||||
|
const lock = readPidLockRecord();
|
||||||
|
if (lock === null) return false;
|
||||||
|
if (!deriveDesktopManagedFromPidLock(lock)) return false;
|
||||||
|
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||||
|
return isProcessRunning(lock.pid);
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
|
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
status: status.status,
|
status: status.status,
|
||||||
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
|||||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||||
const hasRunningLocalProcess = localDaemon === "running";
|
const hasRunningLocalProcess = localDaemon === "running";
|
||||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||||
const desktopManaged = payload.desktopManaged === true;
|
const desktopManaged = readDesktopManagedFromPidLock();
|
||||||
const apiReachable = connectedDaemon === "reachable";
|
const apiReachable = connectedDaemon === "reachable";
|
||||||
let status: DesktopDaemonState = "stopped";
|
let status: DesktopDaemonState = "stopped";
|
||||||
if (apiReachable || hasRunningLocalProcess) {
|
if (apiReachable || hasRunningLocalProcess) {
|
||||||
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
detached: true,
|
detached: true,
|
||||||
envMode: "internal",
|
envMode: "internal",
|
||||||
env: invocation.env,
|
env: invocation.env,
|
||||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
|
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
|
||||||
|
|
||||||
|
describe("desktop managed daemon executable derivation", () => {
|
||||||
|
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||||
|
daemonExecutablePath:
|
||||||
|
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||||
|
platform: "darwin",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a macOS Helper from a different app bundle", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||||
|
daemonExecutablePath:
|
||||||
|
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||||
|
platform: "darwin",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats executables inside the Windows install directory as desktop managed", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||||
|
daemonExecutablePath:
|
||||||
|
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
|
||||||
|
platform: "win32",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects system Node on Windows", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||||
|
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
|
||||||
|
platform: "win32",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats Linux executables from the same install directory as desktop managed", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||||
|
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||||
|
platform: "linux",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects npm-installed CLI daemon executables", () => {
|
||||||
|
expect(
|
||||||
|
deriveDesktopManagedFromExecutablePath({
|
||||||
|
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||||
|
daemonExecutablePath: "/usr/local/bin/node",
|
||||||
|
platform: "linux",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
interface DesktopManagedInput {
|
||||||
|
daemonExecutablePath: string | null | undefined;
|
||||||
|
desktopExecutablePath: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
|
||||||
|
return platform === "win32" ? path.win32 : path.posix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
|
||||||
|
const pathModule = pathModuleForPlatform(platform);
|
||||||
|
const normalized = pathModule.normalize(filePath.trim());
|
||||||
|
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMacAppRoot(filePath: string): string | null {
|
||||||
|
const marker = ".app/";
|
||||||
|
const markerIndex = filePath.indexOf(marker);
|
||||||
|
if (markerIndex === -1) {
|
||||||
|
return filePath.endsWith(".app") ? filePath : null;
|
||||||
|
}
|
||||||
|
return filePath.slice(0, markerIndex + ".app".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDesktopInstallRoot(input: {
|
||||||
|
desktopExecutablePath: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
}): string {
|
||||||
|
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
|
||||||
|
if (input.platform === "darwin") {
|
||||||
|
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathModuleForPlatform(input.platform).dirname(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSamePathOrInside(input: {
|
||||||
|
candidatePath: string;
|
||||||
|
parentPath: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
}): boolean {
|
||||||
|
const pathModule = pathModuleForPlatform(input.platform);
|
||||||
|
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
|
||||||
|
const parentPath = normalizeForComparison(input.parentPath, input.platform);
|
||||||
|
const relative = pathModule.relative(parentPath, candidatePath);
|
||||||
|
return (
|
||||||
|
relative === "" ||
|
||||||
|
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
|
||||||
|
if (!input.daemonExecutablePath?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopInstallRoot = resolveDesktopInstallRoot({
|
||||||
|
desktopExecutablePath: input.desktopExecutablePath,
|
||||||
|
platform: input.platform,
|
||||||
|
});
|
||||||
|
return isSamePathOrInside({
|
||||||
|
candidatePath: input.daemonExecutablePath,
|
||||||
|
parentPath: desktopInstallRoot,
|
||||||
|
platform: input.platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { SnapshotPage } from "./snapshot-engine.js";
|
||||||
|
|
||||||
|
export interface ActionablePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionableTarget {
|
||||||
|
point: ActionablePoint;
|
||||||
|
rect: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionabilityResult =
|
||||||
|
| { ok: true; target: ActionableTarget }
|
||||||
|
| { ok: false; reason: "stale_ref" | "timeout"; detail?: string };
|
||||||
|
|
||||||
|
const DEFAULT_ACTIONABILITY_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export async function waitForActionableTarget(input: {
|
||||||
|
page: SnapshotPage;
|
||||||
|
elementExpression: string;
|
||||||
|
editable?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<ActionabilityResult> {
|
||||||
|
const result = await input.page.executeJavaScript(
|
||||||
|
buildActionabilityScript({
|
||||||
|
elementExpression: input.elementExpression,
|
||||||
|
editable: input.editable === true,
|
||||||
|
timeoutMs: input.timeoutMs ?? DEFAULT_ACTIONABILITY_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return readActionabilityResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActionabilityResult(value: unknown): ActionabilityResult {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { ok: false, reason: "timeout" };
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.ok === true && isActionableTarget(record.target)) {
|
||||||
|
return { ok: true, target: record.target };
|
||||||
|
}
|
||||||
|
if (record.ok === false) {
|
||||||
|
const reason = record.reason;
|
||||||
|
if (reason === "stale_ref" || reason === "timeout") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
...(typeof record.detail === "string" ? { detail: record.detail } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, reason: "timeout" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionableTarget(value: unknown): value is ActionableTarget {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return isPoint(record.point) && isRect(record.rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPoint(value: unknown): value is ActionablePoint {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return isFiniteNumber(record.x) && isFiniteNumber(record.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRect(value: unknown): value is ActionableTarget["rect"] {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
isFiniteNumber(record.x) &&
|
||||||
|
isFiniteNumber(record.y) &&
|
||||||
|
isFiniteNumber(record.width) &&
|
||||||
|
isFiniteNumber(record.height)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionabilityScript(input: {
|
||||||
|
elementExpression: string;
|
||||||
|
editable: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): string {
|
||||||
|
return String.raw`(async () => {
|
||||||
|
const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)};
|
||||||
|
const requiresEditable = ${JSON.stringify(input.editable)};
|
||||||
|
|
||||||
|
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25;
|
||||||
|
const sameRect = (a, b) =>
|
||||||
|
nearlyEqual(a.x, b.x) &&
|
||||||
|
nearlyEqual(a.y, b.y) &&
|
||||||
|
nearlyEqual(a.width, b.width) &&
|
||||||
|
nearlyEqual(a.height, b.height);
|
||||||
|
const rectPayload = (rect) => ({
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
});
|
||||||
|
const centerPoint = (rect) => ({
|
||||||
|
x: Math.min(Math.max(rect.left + rect.width / 2, 0), Math.max(window.innerWidth - 1, 0)),
|
||||||
|
y: Math.min(Math.max(rect.top + rect.height / 2, 0), Math.max(window.innerHeight - 1, 0)),
|
||||||
|
});
|
||||||
|
const isDisabled = (element) => {
|
||||||
|
if (element.closest?.('[aria-disabled="true"]')) return true;
|
||||||
|
if ('disabled' in element && element.disabled) return true;
|
||||||
|
const fieldset = element.closest?.('fieldset[disabled]');
|
||||||
|
return Boolean(fieldset);
|
||||||
|
};
|
||||||
|
const isEditable = (element) => {
|
||||||
|
if (element.isContentEditable) return true;
|
||||||
|
const tag = element.tagName?.toLowerCase();
|
||||||
|
if (tag === 'textarea' || tag === 'select') return !element.readOnly && !isDisabled(element);
|
||||||
|
if (tag !== 'input') return false;
|
||||||
|
const type = (element.getAttribute('type') || 'text').toLowerCase();
|
||||||
|
if (['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(type)) return false;
|
||||||
|
return !element.readOnly && !isDisabled(element);
|
||||||
|
};
|
||||||
|
const isVisible = (element, rect) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.visibility !== 'hidden' &&
|
||||||
|
style.display !== 'none' &&
|
||||||
|
Number(style.opacity || '1') !== 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const hitTargetReceivesEvents = (element, point) => {
|
||||||
|
const hit = document.elementFromPoint(point.x, point.y);
|
||||||
|
return Boolean(hit && (hit === element || element.contains(hit)));
|
||||||
|
};
|
||||||
|
const resolveElement = () => (${input.elementExpression});
|
||||||
|
|
||||||
|
let detail = 'not actionable';
|
||||||
|
while (performance.now() <= deadline) {
|
||||||
|
const element = resolveElement();
|
||||||
|
if (!element || !element.isConnected) {
|
||||||
|
return { ok: false, reason: 'stale_ref', detail: 'ref no longer resolves' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (!isVisible(element, rect)) {
|
||||||
|
detail = 'not visible';
|
||||||
|
await sleep(25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isDisabled(element)) {
|
||||||
|
detail = 'disabled';
|
||||||
|
await sleep(25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (requiresEditable && !isEditable(element)) {
|
||||||
|
detail = 'not editable';
|
||||||
|
await sleep(25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
||||||
|
await nextFrame();
|
||||||
|
const firstRect = element.getBoundingClientRect();
|
||||||
|
await nextFrame();
|
||||||
|
const secondRect = element.getBoundingClientRect();
|
||||||
|
if (!sameRect(firstRect, secondRect)) {
|
||||||
|
detail = 'moving';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = centerPoint(secondRect);
|
||||||
|
if (!hitTargetReceivesEvents(element, point)) {
|
||||||
|
detail = 'covered';
|
||||||
|
await sleep(25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, target: { point, rect: rectPayload(secondRect) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: 'timeout', detail };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
export const ARIA_SNAPSHOT_SCRIPT_MARKER = "__PASEO_ARIA_SNAPSHOT__";
|
||||||
|
|
||||||
|
// Adapted from Playwright's injected ARIA snapshot model.
|
||||||
|
// Copyright (c) Microsoft Corporation. Licensed under the Apache License, Version 2.0.
|
||||||
|
export const ARIA_SNAPSHOT_SCRIPT = String.raw`(() => {
|
||||||
|
const MARKER = ${JSON.stringify(ARIA_SNAPSHOT_SCRIPT_MARKER)};
|
||||||
|
const MAX_NODES = 1500;
|
||||||
|
const MAX_REFS = 500;
|
||||||
|
const MAX_TEXT_LENGTH = 80000;
|
||||||
|
const ACTIONABLE_ROLES = new Set([
|
||||||
|
'button',
|
||||||
|
'checkbox',
|
||||||
|
'combobox',
|
||||||
|
'link',
|
||||||
|
'menuitem',
|
||||||
|
'option',
|
||||||
|
'radio',
|
||||||
|
'searchbox',
|
||||||
|
'slider',
|
||||||
|
'spinbutton',
|
||||||
|
'switch',
|
||||||
|
'tab',
|
||||||
|
'textbox',
|
||||||
|
'treeitem'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityFor(element) {
|
||||||
|
if (!(element instanceof Element)) return false;
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0 ? 'box' : 'boxless';
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitRole(element) {
|
||||||
|
const role = element.getAttribute('role');
|
||||||
|
if (!role || role === 'presentation' || role === 'none') return null;
|
||||||
|
return role.split(/\s+/)[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function implicitRole(element) {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
if (/^h[1-6]$/.test(tag)) return 'heading';
|
||||||
|
if (tag === 'a' && element.hasAttribute('href')) return 'link';
|
||||||
|
if (tag === 'button') return 'button';
|
||||||
|
if (tag === 'select') return 'combobox';
|
||||||
|
if (tag === 'textarea') return 'textbox';
|
||||||
|
if (element instanceof HTMLElement && element.isContentEditable) return 'textbox';
|
||||||
|
if (tag === 'summary') return 'button';
|
||||||
|
if (tag === 'main') return 'main';
|
||||||
|
if (tag === 'nav') return 'navigation';
|
||||||
|
if (tag === 'header') return 'banner';
|
||||||
|
if (tag === 'footer') return 'contentinfo';
|
||||||
|
if (tag === 'section') return element.getAttribute('aria-label') ? 'region' : null;
|
||||||
|
if (tag === 'ul' || tag === 'ol') return 'list';
|
||||||
|
if (tag === 'li') return 'listitem';
|
||||||
|
if (tag === 'table') return 'table';
|
||||||
|
if (tag === 'tr') return 'row';
|
||||||
|
if (tag === 'th') return 'columnheader';
|
||||||
|
if (tag === 'td') return 'cell';
|
||||||
|
if (tag === 'iframe') return 'iframe';
|
||||||
|
if (tag === 'input') {
|
||||||
|
const type = (element.getAttribute('type') || 'text').toLowerCase();
|
||||||
|
if (type === 'checkbox') return 'checkbox';
|
||||||
|
if (type === 'radio') return 'radio';
|
||||||
|
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
||||||
|
if (type === 'range') return 'slider';
|
||||||
|
if (type === 'number') return 'spinbutton';
|
||||||
|
if (type === 'search') return 'searchbox';
|
||||||
|
if (type === 'hidden') return null;
|
||||||
|
return 'textbox';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleFor(element) {
|
||||||
|
return explicitRole(element) || implicitRole(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelText(element) {
|
||||||
|
if (!(element instanceof HTMLElement)) return '';
|
||||||
|
if (element.id) {
|
||||||
|
const escapedId = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
|
? window.CSS.escape(element.id)
|
||||||
|
: String(element.id).replace(/"/g, '\\"');
|
||||||
|
const label = document.querySelector('label[for="' + escapedId + '"]');
|
||||||
|
if (label) return normalizeText(label.textContent);
|
||||||
|
}
|
||||||
|
const closestLabel = element.closest('label');
|
||||||
|
return closestLabel ? normalizeText(closestLabel.textContent) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameFor(element, role) {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
const labelledBy = element.getAttribute('aria-labelledby');
|
||||||
|
if (labelledBy) {
|
||||||
|
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
const pieces = [
|
||||||
|
element.getAttribute('aria-label'),
|
||||||
|
labelText(element),
|
||||||
|
element.getAttribute('alt'),
|
||||||
|
element.getAttribute('title'),
|
||||||
|
tag === 'input' || tag === 'textarea' ? element.getAttribute('placeholder') : null,
|
||||||
|
tag === 'input' || tag === 'textarea' ? element.value : null,
|
||||||
|
role === 'button' || role === 'link' || role === 'heading' || ACTIONABLE_ROLES.has(role)
|
||||||
|
? element.textContent
|
||||||
|
: null
|
||||||
|
];
|
||||||
|
return normalizeText(pieces.find((piece) => normalizeText(piece).length > 0) || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintNameFor(element, role, name) {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
if (tag !== 'input' && tag !== 'textarea') return name;
|
||||||
|
const mutableValue = normalizeText(element.value);
|
||||||
|
if (!mutableValue || name !== mutableValue) return name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inheritedDisabled(element) {
|
||||||
|
if (!(element instanceof Element)) return false;
|
||||||
|
if (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') return true;
|
||||||
|
if (element.closest('fieldset[disabled]')) return true;
|
||||||
|
return element.closest('[aria-disabled="true"]') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionable(element, role) {
|
||||||
|
if (!role || !ACTIONABLE_ROLES.has(role)) return false;
|
||||||
|
if (visibilityFor(element) !== 'box') return false;
|
||||||
|
if (inheritedDisabled(element)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintFor(element, role, name) {
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
name: fingerprintNameFor(element, role, name),
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
type: element.getAttribute('type') || '',
|
||||||
|
ariaLabel: element.getAttribute('aria-label') || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintMatches(element, fingerprint) {
|
||||||
|
const role = roleFor(element) || 'generic';
|
||||||
|
const name = nameFor(element, role);
|
||||||
|
const current = fingerprintFor(element, role, name);
|
||||||
|
return current.role === fingerprint.role &&
|
||||||
|
current.name === fingerprint.name &&
|
||||||
|
current.tagName === fingerprint.tagName &&
|
||||||
|
current.type === fingerprint.type &&
|
||||||
|
current.ariaLabel === fingerprint.ariaLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRuntime() {
|
||||||
|
const runtime = {
|
||||||
|
refs: new Map(),
|
||||||
|
resolve(ref, fingerprint) {
|
||||||
|
const element = this.refs.get(ref);
|
||||||
|
if (!element || !element.isConnected || !fingerprintMatches(element, fingerprint)) {
|
||||||
|
return { ok: false, reason: 'stale_ref' };
|
||||||
|
}
|
||||||
|
return { ok: true, element };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, '__PASEO_BROWSER_AUTOMATION__', {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
value: runtime
|
||||||
|
});
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateAttributes(element, role) {
|
||||||
|
const attrs = [];
|
||||||
|
if (role === 'heading') {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
const level = /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : Number(element.getAttribute('aria-level') || 0);
|
||||||
|
if (level) attrs.push('level=' + level);
|
||||||
|
}
|
||||||
|
if (element.matches?.('input[type="checkbox"], input[type="radio"]')) {
|
||||||
|
attrs.push('checked=' + (element.checked ? 'true' : 'false'));
|
||||||
|
}
|
||||||
|
if (element.getAttribute('aria-checked')) attrs.push('checked=' + element.getAttribute('aria-checked'));
|
||||||
|
if (element.getAttribute('aria-expanded')) attrs.push('expanded=' + element.getAttribute('aria-expanded'));
|
||||||
|
if (element.getAttribute('aria-pressed')) attrs.push('pressed=' + element.getAttribute('aria-pressed'));
|
||||||
|
if (element.getAttribute('aria-selected')) attrs.push('selected=' + element.getAttribute('aria-selected'));
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textNode(text) {
|
||||||
|
return { kind: 'text', text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementNode(element, role, name) {
|
||||||
|
return {
|
||||||
|
kind: 'role',
|
||||||
|
role: role || 'generic',
|
||||||
|
name,
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
attributes: stateAttributes(element, role),
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodeCount = 0;
|
||||||
|
let refCount = 0;
|
||||||
|
let iframeCount = 0;
|
||||||
|
let maxDepth = 0;
|
||||||
|
let truncated = false;
|
||||||
|
let textBudget = MAX_TEXT_LENGTH;
|
||||||
|
const runtime = ensureRuntime();
|
||||||
|
|
||||||
|
function countNode(depth) {
|
||||||
|
if (nodeCount >= MAX_NODES) {
|
||||||
|
truncated = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
nodeCount += 1;
|
||||||
|
maxDepth = Math.max(maxDepth, depth);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cappedText(text) {
|
||||||
|
if (text.length <= textBudget) {
|
||||||
|
textBudget -= text.length;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
truncated = true;
|
||||||
|
const capped = text.slice(0, Math.max(0, textBudget));
|
||||||
|
textBudget = 0;
|
||||||
|
return capped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitNode(domNode, depth) {
|
||||||
|
if (!countNode(depth)) return null;
|
||||||
|
if (domNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = cappedText(normalizeText(domNode.textContent));
|
||||||
|
if (!text) return null;
|
||||||
|
return textNode(text);
|
||||||
|
}
|
||||||
|
if (!(domNode instanceof Element)) return null;
|
||||||
|
const visibility = visibilityFor(domNode);
|
||||||
|
if (!visibility) return null;
|
||||||
|
if (domNode.getAttribute('aria-hidden') === 'true') return null;
|
||||||
|
|
||||||
|
const role = roleFor(domNode);
|
||||||
|
const name = role ? nameFor(domNode, role) : '';
|
||||||
|
const children = [];
|
||||||
|
for (const child of Array.from(domNode.childNodes)) {
|
||||||
|
const childSnapshot = visitNode(child, depth + 1);
|
||||||
|
if (childSnapshot) children.push(childSnapshot);
|
||||||
|
if (truncated) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domNode.tagName.toLowerCase() === 'iframe') {
|
||||||
|
iframeCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibility === 'boxless') {
|
||||||
|
return children.length > 0 ? { kind: 'group', children } : null;
|
||||||
|
}
|
||||||
|
if (!role && children.length === 0) return null;
|
||||||
|
const snapshotNode = role ? elementNode(domNode, role, name) : { kind: 'group', children: [] };
|
||||||
|
snapshotNode.children = children;
|
||||||
|
if (role && isActionable(domNode, role) && refCount < MAX_REFS) {
|
||||||
|
const ref = '@e' + (refCount + 1);
|
||||||
|
const fingerprint = fingerprintFor(domNode, role, name);
|
||||||
|
refCount += 1;
|
||||||
|
runtime.refs.set(ref, domNode);
|
||||||
|
snapshotNode.ref = ref;
|
||||||
|
snapshotNode.fingerprint = fingerprint;
|
||||||
|
} else if (role && isActionable(domNode, role)) {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
return snapshotNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = {
|
||||||
|
kind: 'role',
|
||||||
|
role: 'document',
|
||||||
|
name: normalizeText(document.title),
|
||||||
|
tagName: 'document',
|
||||||
|
attributes: [],
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
for (const child of Array.from(document.body ? document.body.childNodes : document.documentElement.childNodes)) {
|
||||||
|
const childSnapshot = visitNode(child, 1);
|
||||||
|
if (childSnapshot) root.children.push(childSnapshot);
|
||||||
|
if (truncated) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
marker: MARKER,
|
||||||
|
root,
|
||||||
|
refs: Array.from(runtime.refs.entries()).map(([ref, element]) => {
|
||||||
|
const role = roleFor(element) || 'generic';
|
||||||
|
const name = nameFor(element, role);
|
||||||
|
return { ref, fingerprint: fingerprintFor(element, role, name) };
|
||||||
|
}),
|
||||||
|
truncated,
|
||||||
|
stats: { nodeCount, refCount, textLength: 0, iframeCount, maxDepth }
|
||||||
|
});
|
||||||
|
})()`;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type CdpCommandSender = (
|
||||||
|
command: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
export class CdpSessionQueue {
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
public async run<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = this.queue;
|
||||||
|
let releaseCurrent = () => {};
|
||||||
|
const current = new Promise<void>((resolve) => {
|
||||||
|
releaseCurrent = resolve;
|
||||||
|
});
|
||||||
|
const tail = previous.catch(() => {}).then(() => current);
|
||||||
|
this.queue = tail;
|
||||||
|
|
||||||
|
await previous.catch(() => {});
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
releaseCurrent();
|
||||||
|
if (this.queue === tail) {
|
||||||
|
this.queue = Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { BrowserAutomationDialogEvent } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
|
|
||||||
|
export const MAX_DIALOGS_PER_COMMAND = 20;
|
||||||
|
|
||||||
|
export interface JavaScriptDialogOpening {
|
||||||
|
type?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
defaultPrompt?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogPolicy {
|
||||||
|
readonly action: BrowserAutomationDialogEvent["action"];
|
||||||
|
readonly accept: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIALOG_POLICIES: Record<BrowserAutomationDialogEvent["type"], DialogPolicy> = {
|
||||||
|
alert: { action: "accepted", accept: true },
|
||||||
|
confirm: { action: "dismissed", accept: false },
|
||||||
|
prompt: { action: "dismissed", accept: false },
|
||||||
|
beforeunload: { action: "dismissed", accept: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handledDialogEvent(opening: JavaScriptDialogOpening): BrowserAutomationDialogEvent {
|
||||||
|
const type = normalizeDialogType(opening.type);
|
||||||
|
const policy = DIALOG_POLICIES[type];
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message: typeof opening.message === "string" ? opening.message : String(opening.message ?? ""),
|
||||||
|
...(typeof opening.defaultPrompt === "string" ? { defaultValue: opening.defaultPrompt } : {}),
|
||||||
|
action: policy.action,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dialogAcceptValue(type: BrowserAutomationDialogEvent["type"]): boolean {
|
||||||
|
return DIALOG_POLICIES[type].accept;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptShimInstallScript(): string {
|
||||||
|
return String.raw`(() => {
|
||||||
|
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
|
||||||
|
const state = window[stateKey] || { prompts: [], installed: false };
|
||||||
|
window[stateKey] = state;
|
||||||
|
if (state.installed) return true;
|
||||||
|
const originalPrompt = window.prompt;
|
||||||
|
Object.defineProperty(state, "originalPrompt", { configurable: true, value: originalPrompt });
|
||||||
|
const promptShim = (message = "", defaultValue = "") => {
|
||||||
|
state.prompts.push({
|
||||||
|
type: "prompt",
|
||||||
|
message: String(message ?? ""),
|
||||||
|
defaultValue: String(defaultValue ?? ""),
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
Object.defineProperty(state, "promptShim", { configurable: true, value: promptShim });
|
||||||
|
window.prompt = promptShim;
|
||||||
|
state.installed = true;
|
||||||
|
return true;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptShimDrainScript(): string {
|
||||||
|
return String.raw`(() => {
|
||||||
|
const state = window.__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__;
|
||||||
|
if (!state || !Array.isArray(state.prompts)) return [];
|
||||||
|
return state.prompts.splice(0);
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptShimRestoreScript(): string {
|
||||||
|
return String.raw`(() => {
|
||||||
|
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
|
||||||
|
const state = window[stateKey];
|
||||||
|
if (!state || !state.installed) return true;
|
||||||
|
if (window.prompt === state.promptShim && typeof state.originalPrompt === "function") {
|
||||||
|
window.prompt = state.originalPrompt;
|
||||||
|
}
|
||||||
|
delete window[stateKey];
|
||||||
|
return true;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDialogType(value: unknown): BrowserAutomationDialogEvent["type"] {
|
||||||
|
return value === "alert" || value === "confirm" || value === "prompt" || value === "beforeunload"
|
||||||
|
? value
|
||||||
|
: "alert";
|
||||||
|
}
|
||||||
@@ -1,18 +1,552 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import type { Rectangle } from "electron";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import type { TabImage } from "./service.js";
|
||||||
|
import { adaptWebContents } from "./ipc.js";
|
||||||
|
|
||||||
import { sanitizeDownloadFileName } from "./ipc.js";
|
class FakeImage implements TabImage {
|
||||||
|
public toPNG(): Uint8Array {
|
||||||
|
return new Uint8Array([137, 80, 78, 71]);
|
||||||
|
}
|
||||||
|
|
||||||
describe("browser automation IPC", () => {
|
public getSize(): { width: number; height: number } {
|
||||||
it("strips directories from agent-supplied download filenames", () => {
|
return { width: 640, height: 480 };
|
||||||
expect(
|
}
|
||||||
sanitizeDownloadFileName({
|
}
|
||||||
url: "https://example.com/fallback.txt",
|
|
||||||
fileName: "../../.ssh/authorized_keys",
|
class FakeDebugger {
|
||||||
}),
|
public attachedProtocolVersions: string[] = [];
|
||||||
).toBe("authorized_keys");
|
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
|
||||||
|
public blockCommands = false;
|
||||||
|
public readonly blockedCommandNames = new Set<string>();
|
||||||
|
public readonly failedCommandNames = new Set<string>();
|
||||||
|
public readonly promptDialogs: unknown[] = [];
|
||||||
|
public failPromptDrain = false;
|
||||||
|
private messageListener:
|
||||||
|
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
|
||||||
|
| null = null;
|
||||||
|
private readonly blockedCommands: Array<() => void> = [];
|
||||||
|
|
||||||
|
public isAttached(): boolean {
|
||||||
|
return this.attachedProtocolVersions.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public attach(protocolVersion?: string): void {
|
||||||
|
this.attachedProtocolVersions.push(protocolVersion ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
this.commands.push({ command, params: params ?? {} });
|
||||||
|
if (this.failedCommandNames.has(command)) {
|
||||||
|
throw new Error(`${command} failed`);
|
||||||
|
}
|
||||||
|
if (this.blockCommands || this.blockedCommandNames.has(command)) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.blockedCommands.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "Runtime.evaluate" && typeof params?.expression === "string") {
|
||||||
|
if (params.expression.includes("state.prompts.splice(0)")) {
|
||||||
|
if (this.failPromptDrain) {
|
||||||
|
throw new Error("execution context destroyed");
|
||||||
|
}
|
||||||
|
return { result: { value: this.promptDialogs.splice(0) } };
|
||||||
|
}
|
||||||
|
return { result: { value: true } };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(
|
||||||
|
event: "message",
|
||||||
|
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
|
||||||
|
): void {
|
||||||
|
expect(event).toBe("message");
|
||||||
|
this.messageListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public emitMessage(method: string, params?: Record<string, unknown>): void {
|
||||||
|
if (!this.messageListener) {
|
||||||
|
throw new Error("Debugger message listener was not registered");
|
||||||
|
}
|
||||||
|
this.messageListener({}, method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finishNextCommand(): void {
|
||||||
|
const resolve = this.blockedCommands.shift();
|
||||||
|
if (!resolve) {
|
||||||
|
throw new Error("No command is blocked");
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConsoleMessageListener = (
|
||||||
|
event: unknown,
|
||||||
|
level: unknown,
|
||||||
|
message: unknown,
|
||||||
|
line: unknown,
|
||||||
|
sourceId: unknown,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
class FakeWebContents {
|
||||||
|
public readonly debugger = new FakeDebugger();
|
||||||
|
public readonly captures: Array<{
|
||||||
|
rect: Rectangle | undefined;
|
||||||
|
options: { stayHidden?: boolean } | undefined;
|
||||||
|
}> = [];
|
||||||
|
public readonly invalidations: string[] = [];
|
||||||
|
private consoleMessageListener: ConsoleMessageListener | null = null;
|
||||||
|
private destroyedListener: (() => void) | null = null;
|
||||||
|
public destroyed = false;
|
||||||
|
|
||||||
|
public constructor(public readonly id: number) {}
|
||||||
|
|
||||||
|
public getURL(): string {
|
||||||
|
return "https://example.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTitle(): string {
|
||||||
|
return "Example";
|
||||||
|
}
|
||||||
|
|
||||||
|
public canGoBack(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public canGoForward(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLoading(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isDestroyed(): boolean {
|
||||||
|
return this.destroyed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeJavaScript(): Promise<unknown> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadURL(): Promise<void> {}
|
||||||
|
|
||||||
|
public goBack(): void {}
|
||||||
|
|
||||||
|
public goForward(): void {}
|
||||||
|
|
||||||
|
public reload(): void {}
|
||||||
|
|
||||||
|
public async capturePage(
|
||||||
|
rect?: Rectangle,
|
||||||
|
options?: { stayHidden?: boolean },
|
||||||
|
): Promise<TabImage> {
|
||||||
|
this.captures.push({ rect, options });
|
||||||
|
return new FakeImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public invalidate(): void {
|
||||||
|
this.invalidations.push("invalidate");
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(event: "console-message", listener: ConsoleMessageListener): void {
|
||||||
|
expect(event).toBe("console-message");
|
||||||
|
this.consoleMessageListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public once(event: "destroyed", listener: () => void): void {
|
||||||
|
expect(event).toBe("destroyed");
|
||||||
|
this.destroyedListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public emitConsoleMessage(input: {
|
||||||
|
level: unknown;
|
||||||
|
message: unknown;
|
||||||
|
line: unknown;
|
||||||
|
sourceId: unknown;
|
||||||
|
}): void {
|
||||||
|
if (!this.consoleMessageListener) {
|
||||||
|
throw new Error("Console listener was not registered");
|
||||||
|
}
|
||||||
|
this.consoleMessageListener({}, input.level, input.message, input.line, input.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.destroyedListener?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser automation IPC adapter", () => {
|
||||||
|
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
|
||||||
|
const contents = new FakeWebContents(20);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
const image = await tab.capturePage({ stayHidden: false });
|
||||||
|
tab.invalidate();
|
||||||
|
|
||||||
|
expect(image.getSize()).toEqual({ width: 640, height: 480 });
|
||||||
|
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
|
||||||
|
expect(contents.invalidations).toEqual(["invalidate"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a safe filename when the URL has no basename", () => {
|
test("collects console messages until the guest is destroyed", () => {
|
||||||
expect(sanitizeDownloadFileName({ url: "https://example.com/" })).toBe("download");
|
const contents = new FakeWebContents(21);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
contents.emitConsoleMessage({
|
||||||
|
level: "warning",
|
||||||
|
message: "hello",
|
||||||
|
line: 12,
|
||||||
|
sourceId: "https://example.com/app.js",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tab.getConsoleMessages?.()).toEqual([
|
||||||
|
{
|
||||||
|
level: "warning",
|
||||||
|
message: "hello",
|
||||||
|
line: 12,
|
||||||
|
source: "https://example.com/app.js",
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
contents.destroy();
|
||||||
|
|
||||||
|
expect(tab.getConsoleMessages?.()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attaches the debugger before sending a CDP command", async () => {
|
||||||
|
const contents = new FakeWebContents(22);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
const result = await tab.sendDebugCommand?.("Page.captureScreenshot", {
|
||||||
|
format: "png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Page.captureScreenshot", params: { format: "png" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes CDP commands per guest contents", async () => {
|
||||||
|
const contents = new FakeWebContents(23);
|
||||||
|
contents.debugger.blockCommands = true;
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
const first = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseMoved" });
|
||||||
|
const second = tab.sendDebugCommand?.("Page.captureScreenshot", { format: "png" });
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
contents.debugger.finishNextCommand();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
|
||||||
|
{ command: "Page.captureScreenshot", params: { format: "png" } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
contents.debugger.finishNextCommand();
|
||||||
|
await expect(first).resolves.toEqual({ ok: true });
|
||||||
|
await expect(second).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles JavaScript dialogs through the per-tab CDP queue", async () => {
|
||||||
|
const contents = new FakeWebContents(24);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
const captured = tab.captureDialogs?.(async () => {
|
||||||
|
contents.debugger.blockCommands = true;
|
||||||
|
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseReleased" });
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "Delete item?",
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Page.enable", params: {} },
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
|
||||||
|
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
contents.debugger.blockCommands = false;
|
||||||
|
contents.debugger.finishNextCommand();
|
||||||
|
await input;
|
||||||
|
await flushMicrotasks();
|
||||||
|
return "done";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(captured).resolves.toEqual({
|
||||||
|
result: "done",
|
||||||
|
dialogs: [
|
||||||
|
{
|
||||||
|
type: "confirm",
|
||||||
|
message: "Delete item?",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Page.enable", params: {} },
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
|
||||||
|
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles JavaScript dialogs while the triggering CDP input command is still in flight", async () => {
|
||||||
|
const contents = new FakeWebContents(25);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
const captured = tab.captureDialogs?.(async () => {
|
||||||
|
contents.debugger.blockedCommandNames.add("Input.dispatchMouseEvent");
|
||||||
|
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mousePressed" });
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
|
||||||
|
type: "alert",
|
||||||
|
message: "Saved",
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Page.enable", params: {} },
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{ command: "Input.dispatchMouseEvent", params: { type: "mousePressed" } },
|
||||||
|
{ command: "Page.handleJavaScriptDialog", params: { accept: true } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
contents.debugger.blockedCommandNames.clear();
|
||||||
|
contents.debugger.finishNextCommand();
|
||||||
|
await input;
|
||||||
|
return "done";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(captured).resolves.toEqual({
|
||||||
|
result: "done",
|
||||||
|
dialogs: [
|
||||||
|
{
|
||||||
|
type: "alert",
|
||||||
|
message: "Saved",
|
||||||
|
action: "accepted",
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(contents.debugger.commands.at(-1)).toEqual({
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: {
|
||||||
|
expression: expect.stringContaining("delete window[stateKey]"),
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the prompt shim installed until overlapping captures finish", async () => {
|
||||||
|
const contents = new FakeWebContents(26);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
const firstStarted = deferred<void>();
|
||||||
|
const secondStarted = deferred<void>();
|
||||||
|
const finishFirst = deferred<void>();
|
||||||
|
const finishSecond = deferred<void>();
|
||||||
|
|
||||||
|
const first = tab.captureDialogs?.(async () => {
|
||||||
|
firstStarted.resolve();
|
||||||
|
await finishFirst.promise;
|
||||||
|
return "first";
|
||||||
|
});
|
||||||
|
await firstStarted.promise;
|
||||||
|
|
||||||
|
const second = tab.captureDialogs?.(async () => {
|
||||||
|
secondStarted.resolve();
|
||||||
|
await finishSecond.promise;
|
||||||
|
return "second";
|
||||||
|
});
|
||||||
|
await secondStarted.promise;
|
||||||
|
|
||||||
|
contents.debugger.promptDialogs.push(
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "First?",
|
||||||
|
defaultValue: "one",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "Second?",
|
||||||
|
defaultValue: "two",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
finishFirst.resolve();
|
||||||
|
await expect(first).resolves.toEqual({
|
||||||
|
result: "first",
|
||||||
|
dialogs: [
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "First?",
|
||||||
|
defaultValue: "one",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "Second?",
|
||||||
|
defaultValue: "two",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
contents.debugger.commands.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.command === "Runtime.evaluate" &&
|
||||||
|
typeof entry.params.expression === "string" &&
|
||||||
|
entry.params.expression.includes("delete window[stateKey]"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
finishSecond.resolve();
|
||||||
|
await expect(second).resolves.toEqual({
|
||||||
|
result: "second",
|
||||||
|
dialogs: [
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "First?",
|
||||||
|
defaultValue: "one",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: "Second?",
|
||||||
|
defaultValue: "two",
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(contents.debugger.commands.at(-1)).toEqual({
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: {
|
||||||
|
expression: expect.stringContaining("delete window[stateKey]"),
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves JavaScript dialogs alone when no capture is active", async () => {
|
||||||
|
const contents = new FakeWebContents(28);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
|
||||||
|
result: "done",
|
||||||
|
dialogs: [],
|
||||||
|
});
|
||||||
|
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "Unsaved changes?",
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).not.toContainEqual({
|
||||||
|
command: "Page.handleJavaScriptDialog",
|
||||||
|
params: { accept: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats prompt shim drain failures after navigation as no dialogs", async () => {
|
||||||
|
const contents = new FakeWebContents(29);
|
||||||
|
contents.debugger.failPromptDrain = true;
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
await expect(tab.captureDialogs?.(async () => "navigated")).resolves.toEqual({
|
||||||
|
result: "navigated",
|
||||||
|
dialogs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contents.debugger.commands).toEqual([
|
||||||
|
{ command: "Page.enable", params: {} },
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: { expression: expect.any(String), returnByValue: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "Runtime.evaluate",
|
||||||
|
params: {
|
||||||
|
expression: expect.stringContaining("delete window[stateKey]"),
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs the command without dialog capture when CDP setup fails", async () => {
|
||||||
|
const contents = new FakeWebContents(30);
|
||||||
|
contents.debugger.failedCommandNames.add("Page.enable");
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
|
||||||
|
result: "done",
|
||||||
|
dialogs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
"[browser-automation] Dialog capture unavailable; running command without it",
|
||||||
|
{ contentsId: 30, error: expect.any(Error) },
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,85 @@
|
|||||||
import { mkdirSync } from "node:fs";
|
import type { Rectangle } from "electron";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { basename, join } from "node:path";
|
|
||||||
import type { WebContents } from "electron";
|
|
||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import type {
|
import type {
|
||||||
BrowserAutomationConsoleLogEntry,
|
BrowserAutomationConsoleLogEntry,
|
||||||
BrowserAutomationCookieEntry,
|
BrowserAutomationDialogEvent,
|
||||||
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import type { TabContents, BrowserRegistry } from "./service.js";
|
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
|
||||||
|
import { CdpSessionQueue } from "./cdp-session-queue.js";
|
||||||
|
import {
|
||||||
|
dialogAcceptValue,
|
||||||
|
handledDialogEvent,
|
||||||
|
MAX_DIALOGS_PER_COMMAND,
|
||||||
|
promptShimDrainScript,
|
||||||
|
promptShimInstallScript,
|
||||||
|
promptShimRestoreScript,
|
||||||
|
} from "./dialog-handling.js";
|
||||||
import { executeAutomationCommand } from "./service.js";
|
import { executeAutomationCommand } from "./service.js";
|
||||||
import {
|
import {
|
||||||
listRegisteredPaseoBrowserIds,
|
listRegisteredPaseoBrowserIds,
|
||||||
listRegisteredPaseoBrowserIdsForWorkspace,
|
listRegisteredPaseoBrowserIdsForWorkspace,
|
||||||
getPaseoBrowserWebContents,
|
getPaseoBrowserWebContents,
|
||||||
getWorkspaceActivePaseoBrowserWebContents,
|
|
||||||
getWorkspaceActivePaseoBrowserId,
|
getWorkspaceActivePaseoBrowserId,
|
||||||
getAgentActivePaseoBrowserId,
|
|
||||||
getPaseoBrowserWorkspaceId,
|
getPaseoBrowserWorkspaceId,
|
||||||
} from "../browser-webviews/index.js";
|
} from "../browser-webviews/index.js";
|
||||||
|
|
||||||
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
|
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
|
||||||
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
|
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
|
||||||
|
const cdpQueuesByContentsId = new Map<number, CdpSessionQueue>();
|
||||||
|
const dialogMonitorsByContentsId = new Map<number, DialogMonitor>();
|
||||||
const observedContentsIds = new Set<number>();
|
const observedContentsIds = new Set<number>();
|
||||||
|
|
||||||
interface IpcHandlerRegistry {
|
interface IpcHandlerRegistry {
|
||||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptWebContents(contents: WebContents): TabContents {
|
interface WebContentsDebugger {
|
||||||
|
isAttached(): boolean;
|
||||||
|
attach(protocolVersion?: string): void;
|
||||||
|
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
on?(
|
||||||
|
event: "message",
|
||||||
|
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsoleMessageEmitter {
|
||||||
|
on(
|
||||||
|
event: "console-message",
|
||||||
|
listener: (
|
||||||
|
event: unknown,
|
||||||
|
level: unknown,
|
||||||
|
message: unknown,
|
||||||
|
line: unknown,
|
||||||
|
sourceId: unknown,
|
||||||
|
) => void,
|
||||||
|
): void;
|
||||||
|
once(event: "destroyed", listener: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
|
||||||
|
readonly id: number;
|
||||||
|
readonly debugger: WebContentsDebugger;
|
||||||
|
getURL(): string;
|
||||||
|
getTitle(): string;
|
||||||
|
canGoBack(): boolean;
|
||||||
|
canGoForward(): boolean;
|
||||||
|
isLoading(): boolean;
|
||||||
|
isDestroyed(): boolean;
|
||||||
|
executeJavaScript(code: string): Promise<unknown>;
|
||||||
|
loadURL(url: string): Promise<void>;
|
||||||
|
goBack(): void;
|
||||||
|
goForward(): void;
|
||||||
|
reload(): void;
|
||||||
|
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
|
||||||
observeConsoleMessages(contents);
|
observeConsoleMessages(contents);
|
||||||
|
const cdpQueue = getCdpQueue(contents.id);
|
||||||
|
const dialogMonitor = getDialogMonitor(contents, cdpQueue);
|
||||||
return {
|
return {
|
||||||
id: contents.id,
|
id: contents.id,
|
||||||
getURL: () => contents.getURL(),
|
getURL: () => contents.getURL(),
|
||||||
@@ -43,67 +93,31 @@ function adaptWebContents(contents: WebContents): TabContents {
|
|||||||
goBack: () => contents.goBack(),
|
goBack: () => contents.goBack(),
|
||||||
goForward: () => contents.goForward(),
|
goForward: () => contents.goForward(),
|
||||||
reload: () => contents.reload(),
|
reload: () => contents.reload(),
|
||||||
capturePage: () => contents.capturePage(),
|
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
|
||||||
|
invalidate: () => contents.invalidate(),
|
||||||
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
|
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
|
||||||
getCookies: async (url: string) =>
|
captureDialogs: (task) => dialogMonitor.capture(task),
|
||||||
(await contents.session.cookies.get({ url })).map(normalizeCookie),
|
sendDebugCommand: (command: string, params?: Record<string, unknown>) =>
|
||||||
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
|
cdpQueue.run(async () => {
|
||||||
if (!contents.debugger.isAttached()) {
|
if (!contents.debugger.isAttached()) {
|
||||||
contents.debugger.attach("1.3");
|
contents.debugger.attach("1.3");
|
||||||
}
|
}
|
||||||
return contents.debugger.sendCommand(command, params ?? {});
|
return contents.debugger.sendCommand(command, params ?? {});
|
||||||
},
|
}),
|
||||||
printToPDF: async (options?: Record<string, unknown>) => contents.printToPDF(options ?? {}),
|
|
||||||
downloadURL: (input) => downloadWithContents(contents, input),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadWithContents(
|
function getCdpQueue(contentsId: number): CdpSessionQueue {
|
||||||
contents: WebContents,
|
const existing = cdpQueuesByContentsId.get(contentsId);
|
||||||
input: { url: string; fileName?: string },
|
if (existing) {
|
||||||
): Promise<{ filePath: string; totalBytes?: number; state: string }> {
|
return existing;
|
||||||
const downloadDir = join(tmpdir(), "paseo-browser-downloads");
|
}
|
||||||
mkdirSync(downloadDir, { recursive: true });
|
const queue = new CdpSessionQueue();
|
||||||
const filePath = join(downloadDir, sanitizeDownloadFileName(input));
|
cdpQueuesByContentsId.set(contentsId, queue);
|
||||||
return new Promise((resolve, reject) => {
|
return queue;
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
contents.session.off("will-download", onDownload);
|
|
||||||
reject(new Error(`Timed out waiting for browser download: ${input.url}`));
|
|
||||||
}, 30_000);
|
|
||||||
function onDownload(_event: Electron.Event, item: Electron.DownloadItem): void {
|
|
||||||
if (item.getURL() !== input.url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(timeout);
|
|
||||||
contents.session.off("will-download", onDownload);
|
|
||||||
item.setSavePath(filePath);
|
|
||||||
item.once("done", (_doneEvent, state) => {
|
|
||||||
resolve({ filePath, totalBytes: item.getTotalBytes(), state });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
contents.session.on("will-download", onDownload);
|
|
||||||
contents.downloadURL(input.url);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeDownloadFileName(input: { url: string; fileName?: string }): string {
|
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
|
||||||
const requestedName = input.fileName ?? basename(new URL(input.url).pathname);
|
|
||||||
return basename(requestedName) || "download";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCookie(cookie: Electron.Cookie): BrowserAutomationCookieEntry {
|
|
||||||
return {
|
|
||||||
name: cookie.name,
|
|
||||||
value: cookie.value,
|
|
||||||
...(cookie.domain ? { domain: cookie.domain } : {}),
|
|
||||||
...(cookie.path ? { path: cookie.path } : {}),
|
|
||||||
secure: cookie.secure,
|
|
||||||
httpOnly: cookie.httpOnly,
|
|
||||||
...(typeof cookie.expirationDate === "number" ? { expirationDate: cookie.expirationDate } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeConsoleMessages(contents: WebContents): void {
|
|
||||||
if (observedContentsIds.has(contents.id)) {
|
if (observedContentsIds.has(contents.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,6 +131,192 @@ function observeConsoleMessages(contents: WebContents): void {
|
|||||||
contents.once("destroyed", () => {
|
contents.once("destroyed", () => {
|
||||||
observedContentsIds.delete(contents.id);
|
observedContentsIds.delete(contents.id);
|
||||||
consoleMessagesByContentsId.delete(contents.id);
|
consoleMessagesByContentsId.delete(contents.id);
|
||||||
|
cdpQueuesByContentsId.delete(contents.id);
|
||||||
|
dialogMonitorsByContentsId.delete(contents.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDialogMonitor(
|
||||||
|
contents: BrowserAutomationWebContents,
|
||||||
|
cdpQueue: CdpSessionQueue,
|
||||||
|
): DialogMonitor {
|
||||||
|
const existing = dialogMonitorsByContentsId.get(contents.id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const monitor = new DialogMonitor(contents, cdpQueue);
|
||||||
|
dialogMonitorsByContentsId.set(contents.id, monitor);
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DialogMonitor {
|
||||||
|
private enabled = false;
|
||||||
|
private listenerRegistered = false;
|
||||||
|
private readonly activeCollectors: DialogCollector[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly contents: BrowserAutomationWebContents,
|
||||||
|
private readonly cdpQueue: CdpSessionQueue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async capture<T>(
|
||||||
|
task: () => Promise<T>,
|
||||||
|
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
|
||||||
|
const collector: DialogCollector = { dialogs: [] };
|
||||||
|
try {
|
||||||
|
await this.enable();
|
||||||
|
await this.installPromptShim();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
|
||||||
|
contentsId: this.contents.id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return { result: await task(), dialogs: [] };
|
||||||
|
}
|
||||||
|
this.activeCollectors.push(collector);
|
||||||
|
try {
|
||||||
|
const result = await task();
|
||||||
|
this.recordPromptShimDialogs(await this.drainPromptShim());
|
||||||
|
return { result, dialogs: collector.dialogs };
|
||||||
|
} finally {
|
||||||
|
const index = this.activeCollectors.indexOf(collector);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.activeCollectors.splice(index, 1);
|
||||||
|
}
|
||||||
|
if (this.activeCollectors.length === 0) {
|
||||||
|
await this.restorePromptShim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enable(): Promise<void> {
|
||||||
|
if (this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.contents.debugger.on) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.listenerRegistered) {
|
||||||
|
this.listenerRegistered = true;
|
||||||
|
this.contents.debugger.on("message", (_event, method, params) => {
|
||||||
|
if (method !== "Page.javascriptDialogOpening") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.activeCollectors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.handleOpening(params ?? {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.sendDebugCommand("Page.enable");
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOpening(params: Record<string, unknown>): Promise<void> {
|
||||||
|
const event = handledDialogEvent(params);
|
||||||
|
for (const collector of this.activeCollectors) {
|
||||||
|
this.recordDialogs(collector, [event]);
|
||||||
|
}
|
||||||
|
await this.sendDialogResponseCommand("Page.handleJavaScriptDialog", {
|
||||||
|
accept: dialogAcceptValue(event.type),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installPromptShim(): Promise<void> {
|
||||||
|
await this.sendDebugCommand("Runtime.evaluate", {
|
||||||
|
expression: promptShimInstallScript(),
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drainPromptShim(): Promise<BrowserAutomationDialogEvent[]> {
|
||||||
|
try {
|
||||||
|
const result = (await this.sendDebugCommand("Runtime.evaluate", {
|
||||||
|
expression: promptShimDrainScript(),
|
||||||
|
returnByValue: true,
|
||||||
|
})) as { result?: { value?: unknown } };
|
||||||
|
return parsePromptShimDialogs(result.result?.value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restorePromptShim(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.sendDebugCommand("Runtime.evaluate", {
|
||||||
|
expression: promptShimRestoreScript(),
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Navigation can destroy the execution context before cleanup runs; the next page has no shim.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordDialogs(collector: DialogCollector, dialogs: BrowserAutomationDialogEvent[]): void {
|
||||||
|
for (const dialog of dialogs) {
|
||||||
|
if (collector.dialogs.length >= MAX_DIALOGS_PER_COMMAND) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collector.dialogs.push(dialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordPromptShimDialogs(dialogs: BrowserAutomationDialogEvent[]): void {
|
||||||
|
for (const collector of this.activeCollectors) {
|
||||||
|
this.recordDialogs(collector, dialogs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendDebugCommand(
|
||||||
|
command: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.cdpQueue.run(async () => {
|
||||||
|
if (!this.contents.debugger.isAttached()) {
|
||||||
|
this.contents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
return this.contents.debugger.sendCommand(command, params ?? {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendDialogResponseCommand(
|
||||||
|
command: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
// Dialogs can block the CDP command that opened them, so the unblocker must not wait behind
|
||||||
|
// the per-tab command queue.
|
||||||
|
if (!this.contents.debugger.isAttached()) {
|
||||||
|
this.contents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
return this.contents.debugger.sendCommand(command, params ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogCollector {
|
||||||
|
dialogs: BrowserAutomationDialogEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePromptShimDialogs(value: unknown): BrowserAutomationDialogEvent[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.flatMap((entry): BrowserAutomationDialogEvent[] => {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
if (record.type !== "prompt" || record.action !== "dismissed") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
message: typeof record.message === "string" ? record.message : "",
|
||||||
|
...(typeof record.defaultValue === "string" ? { defaultValue: record.defaultValue } : {}),
|
||||||
|
action: "dismissed",
|
||||||
|
timestamp: typeof record.timestamp === "number" ? record.timestamp : Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +346,7 @@ function createRegistry(): BrowserRegistry {
|
|||||||
return contents ? adaptWebContents(contents) : null;
|
return contents ? adaptWebContents(contents) : null;
|
||||||
},
|
},
|
||||||
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
|
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
|
||||||
getWorkspaceActiveTabContents(workspaceId: string): TabContents | null {
|
|
||||||
const contents = getWorkspaceActivePaseoBrowserWebContents(workspaceId);
|
|
||||||
return contents ? adaptWebContents(contents) : null;
|
|
||||||
},
|
|
||||||
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
|
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
|
||||||
getAgentActiveBrowserId: getAgentActivePaseoBrowserId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,77 +3,162 @@ import { BrowserSnapshotEngine, type SnapshotPage } from "./snapshot-engine.js";
|
|||||||
|
|
||||||
class SnapshotFixture implements SnapshotPage {
|
class SnapshotFixture implements SnapshotPage {
|
||||||
public currentUrl = "https://example.com/form";
|
public currentUrl = "https://example.com/form";
|
||||||
public actionResult: unknown = true;
|
public actionResult: unknown = { ok: true };
|
||||||
|
public alreadyTruncated = false;
|
||||||
|
public snapshotNodes: unknown[] = [
|
||||||
|
{
|
||||||
|
kind: "role",
|
||||||
|
role: "heading",
|
||||||
|
name: "Settings",
|
||||||
|
tagName: "h1",
|
||||||
|
attributes: ["level=1"],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{ kind: "text", text: "Connected as Maya" },
|
||||||
|
{
|
||||||
|
kind: "role",
|
||||||
|
role: "button",
|
||||||
|
name: "Save changes",
|
||||||
|
tagName: "button",
|
||||||
|
attributes: [],
|
||||||
|
ref: "@e1",
|
||||||
|
fingerprint: {
|
||||||
|
role: "button",
|
||||||
|
name: "Save changes",
|
||||||
|
tagName: "button",
|
||||||
|
type: "",
|
||||||
|
ariaLabel: "",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
public getURL(): string {
|
public getURL(): string {
|
||||||
return this.currentUrl;
|
return this.currentUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeJavaScript(code: string): Promise<unknown> {
|
public async executeJavaScript(code: string): Promise<unknown> {
|
||||||
if (code.includes("CANDIDATE_SELECTOR")) {
|
if (code.includes("__PASEO_ARIA_SNAPSHOT__")) {
|
||||||
return JSON.stringify([
|
return JSON.stringify({
|
||||||
{
|
marker: "__PASEO_ARIA_SNAPSHOT__",
|
||||||
role: "textbox",
|
root: {
|
||||||
tagName: "input",
|
kind: "role",
|
||||||
text: "Name",
|
role: "document",
|
||||||
selector: "#name",
|
name: "Fixture",
|
||||||
attributes: { id: "name", type: "text" },
|
tagName: "document",
|
||||||
|
attributes: [],
|
||||||
|
children: this.snapshotNodes,
|
||||||
},
|
},
|
||||||
{
|
refs: [
|
||||||
role: "button",
|
{
|
||||||
tagName: "button",
|
ref: "@e1",
|
||||||
text: "Drop",
|
fingerprint: {
|
||||||
selector: "#drop",
|
role: "button",
|
||||||
attributes: { id: "drop" },
|
name: "Save changes",
|
||||||
},
|
tagName: "button",
|
||||||
]);
|
type: "",
|
||||||
|
ariaLabel: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
truncated: this.alreadyTruncated,
|
||||||
|
stats: { nodeCount: 4, refCount: 1, textLength: 0, iframeCount: 0, maxDepth: 1 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return this.actionResult;
|
return this.actionResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BrowserSnapshotEngine", () => {
|
describe("BrowserSnapshotEngine", () => {
|
||||||
it("treats a false result from a ref action script as a stale ref", async () => {
|
it("renders a hierarchical ARIA YAML snapshot with static text and actionable refs", async () => {
|
||||||
const page = new SnapshotFixture();
|
const page = new SnapshotFixture();
|
||||||
const engine = new BrowserSnapshotEngine();
|
const engine = new BrowserSnapshotEngine();
|
||||||
await engine.snapshot({ browserId: "browser-1", page });
|
|
||||||
|
|
||||||
page.actionResult = false;
|
await expect(engine.snapshot({ browserId: "browser-1", page })).resolves.toEqual({
|
||||||
|
format: "aria-yaml",
|
||||||
await expect(engine.click({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
|
snapshot: [
|
||||||
ok: false,
|
'- document "Fixture"',
|
||||||
reason: "stale_ref",
|
' - heading "Settings" [level=1]',
|
||||||
});
|
' - text: "Connected as Maya"',
|
||||||
await expect(engine.focus({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
|
' - button "Save changes" [ref=@e1]',
|
||||||
ok: false,
|
].join("\n"),
|
||||||
reason: "stale_ref",
|
truncated: false,
|
||||||
|
stats: { nodeCount: 4, refCount: 1, textLength: 119, iframeCount: 0, maxDepth: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a false result from optional ref text/key actions as a stale ref", async () => {
|
it("builds a runtime ref expression with the snapshot fingerprint", async () => {
|
||||||
const page = new SnapshotFixture();
|
const page = new SnapshotFixture();
|
||||||
const engine = new BrowserSnapshotEngine();
|
const engine = new BrowserSnapshotEngine();
|
||||||
await engine.snapshot({ browserId: "browser-1", page });
|
await engine.snapshot({ browserId: "browser-1", page });
|
||||||
|
|
||||||
page.actionResult = false;
|
page.currentUrl = "https://example.com/form?panel=advanced";
|
||||||
|
|
||||||
await expect(
|
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e1" })).toContain(
|
||||||
engine.typeText({ browserId: "browser-1", page, ref: "@e1", text: "Ada" }),
|
'"name":"Save changes"',
|
||||||
).resolves.toEqual({ ok: false, reason: "stale_ref" });
|
);
|
||||||
await expect(
|
|
||||||
engine.keypress({ browserId: "browser-1", page, ref: "@e1", key: "Enter" }),
|
|
||||||
).resolves.toEqual({ ok: false, reason: "stale_ref" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a false result from drag as a stale ref", async () => {
|
it("treats missing host-side ref metadata as a stale ref", async () => {
|
||||||
const page = new SnapshotFixture();
|
const page = new SnapshotFixture();
|
||||||
const engine = new BrowserSnapshotEngine();
|
const engine = new BrowserSnapshotEngine();
|
||||||
await engine.snapshot({ browserId: "browser-1", page });
|
await engine.snapshot({ browserId: "browser-1", page });
|
||||||
|
|
||||||
page.actionResult = false;
|
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e2" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "missing_ref",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
it("marks rendered output truncation explicitly and deterministically", async () => {
|
||||||
engine.drag({ browserId: "browser-1", page, sourceRef: "@e1", targetRef: "@e2" }),
|
const page = new SnapshotFixture();
|
||||||
).resolves.toEqual({ ok: false, reason: "stale_ref" });
|
page.snapshotNodes = [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
text: "A".repeat(81_000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const engine = new BrowserSnapshotEngine();
|
||||||
|
|
||||||
|
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
|
||||||
|
|
||||||
|
expect(snapshot.truncated).toBe(true);
|
||||||
|
expect(snapshot.snapshot.endsWith('- text: "Snapshot truncated."')).toBe(true);
|
||||||
|
expect(snapshot.stats.textLength).toBeLessThanOrEqual(80_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last rendered node when a short snapshot was capped by node count", async () => {
|
||||||
|
const page = new SnapshotFixture();
|
||||||
|
page.alreadyTruncated = true;
|
||||||
|
page.snapshotNodes = [
|
||||||
|
{
|
||||||
|
kind: "role",
|
||||||
|
role: "button",
|
||||||
|
name: "Final action",
|
||||||
|
tagName: "button",
|
||||||
|
attributes: [],
|
||||||
|
ref: "@e1",
|
||||||
|
fingerprint: {
|
||||||
|
role: "button",
|
||||||
|
name: "Final action",
|
||||||
|
tagName: "button",
|
||||||
|
type: "",
|
||||||
|
ariaLabel: "",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const engine = new BrowserSnapshotEngine();
|
||||||
|
|
||||||
|
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
|
||||||
|
|
||||||
|
expect(snapshot.truncated).toBe(true);
|
||||||
|
expect(snapshot.snapshot).toBe(
|
||||||
|
[
|
||||||
|
'- document "Fixture"',
|
||||||
|
' - button "Final action" [ref=@e1]',
|
||||||
|
'- text: "Snapshot truncated."',
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,60 @@
|
|||||||
|
import { ARIA_SNAPSHOT_SCRIPT, ARIA_SNAPSHOT_SCRIPT_MARKER } from "./aria-snapshot-script.js";
|
||||||
|
|
||||||
export interface SnapshotPage {
|
export interface SnapshotPage {
|
||||||
getURL(): string;
|
getURL(): string;
|
||||||
executeJavaScript(code: string): Promise<unknown>;
|
executeJavaScript(code: string): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserSnapshotElement extends RawSnapshotElement {
|
export interface BrowserAriaSnapshot {
|
||||||
ref: string;
|
format: "aria-yaml";
|
||||||
|
snapshot: string;
|
||||||
|
truncated: boolean;
|
||||||
|
stats: BrowserAriaSnapshotStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawSnapshotElement {
|
export interface BrowserAriaSnapshotStats {
|
||||||
|
nodeCount: number;
|
||||||
|
refCount: number;
|
||||||
|
textLength: number;
|
||||||
|
iframeCount?: number;
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotNode {
|
||||||
|
kind: "role" | "text" | "group";
|
||||||
|
role?: string;
|
||||||
|
name?: string;
|
||||||
|
text?: string;
|
||||||
|
tagName?: string;
|
||||||
|
attributes?: string[];
|
||||||
|
ref?: string;
|
||||||
|
fingerprint?: BrowserRefFingerprint;
|
||||||
|
children?: SnapshotNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserRefFingerprint {
|
||||||
role: string;
|
role: string;
|
||||||
|
name: string;
|
||||||
tagName: string;
|
tagName: string;
|
||||||
text: string;
|
type: string;
|
||||||
selector: string;
|
ariaLabel: string;
|
||||||
attributes: Record<string, string>;
|
}
|
||||||
|
|
||||||
|
interface RawAriaSnapshot {
|
||||||
|
marker: string;
|
||||||
|
root: SnapshotNode;
|
||||||
|
refs: BrowserRefMetadata[];
|
||||||
|
truncated: boolean;
|
||||||
|
stats: BrowserAriaSnapshotStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserRefMetadata {
|
||||||
|
ref: string;
|
||||||
|
fingerprint: BrowserRefFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowserRefState {
|
interface BrowserRefState {
|
||||||
nextRefNumber: number;
|
refs: Map<string, BrowserRefMetadata>;
|
||||||
url: string;
|
|
||||||
refs: Map<string, RawSnapshotElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserRefActionResult =
|
export type BrowserRefActionResult =
|
||||||
@@ -26,45 +62,31 @@ export type BrowserRefActionResult =
|
|||||||
| { ok: false; reason: "stale_ref" | "missing_ref" };
|
| { ok: false; reason: "stale_ref" | "missing_ref" };
|
||||||
type BrowserRefFailure = Extract<BrowserRefActionResult, { ok: false }>;
|
type BrowserRefFailure = Extract<BrowserRefActionResult, { ok: false }>;
|
||||||
|
|
||||||
type BrowserRefResolveResult = { ok: true; element: RawSnapshotElement } | BrowserRefFailure;
|
type BrowserRefResolveResult = { ok: true; metadata: BrowserRefMetadata } | BrowserRefFailure;
|
||||||
|
|
||||||
|
const TRUNCATION_MARKER = '- text: "Snapshot truncated."';
|
||||||
|
const MAX_RENDERED_TEXT_LENGTH = 80_000;
|
||||||
|
|
||||||
export class BrowserSnapshotEngine {
|
export class BrowserSnapshotEngine {
|
||||||
private readonly statesByBrowserId = new Map<string, BrowserRefState>();
|
private readonly statesByBrowserId = new Map<string, BrowserRefState>();
|
||||||
|
|
||||||
async snapshot(input: {
|
async snapshot(input: { browserId: string; page: SnapshotPage }): Promise<BrowserAriaSnapshot> {
|
||||||
browserId: string;
|
const rawSnapshot = parseAriaSnapshot(await input.page.executeJavaScript(ARIA_SNAPSHOT_SCRIPT));
|
||||||
page: SnapshotPage;
|
const rendered = renderSnapshot(rawSnapshot.root);
|
||||||
}): Promise<BrowserSnapshotElement[]> {
|
const capped = capRenderedSnapshot(rendered, rawSnapshot.truncated);
|
||||||
const rawElements = parseRawSnapshotElements(
|
this.statesByBrowserId.set(input.browserId, {
|
||||||
await input.page.executeJavaScript(SNAPSHOT_SCRIPT),
|
refs: new Map(rawSnapshot.refs.map((ref) => [ref.ref, ref])),
|
||||||
);
|
|
||||||
const state = {
|
|
||||||
nextRefNumber: 1,
|
|
||||||
url: input.page.getURL(),
|
|
||||||
refs: new Map<string, RawSnapshotElement>(),
|
|
||||||
};
|
|
||||||
const elements = rawElements.map((element) => {
|
|
||||||
const ref = `@e${state.nextRefNumber++}`;
|
|
||||||
state.refs.set(ref, element);
|
|
||||||
return {
|
|
||||||
ref,
|
|
||||||
role: element.role,
|
|
||||||
tagName: element.tagName,
|
|
||||||
text: element.text,
|
|
||||||
selector: element.selector,
|
|
||||||
attributes: element.attributes,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
this.statesByBrowserId.set(input.browserId, state);
|
return {
|
||||||
return elements;
|
format: "aria-yaml",
|
||||||
}
|
snapshot: capped.snapshot,
|
||||||
|
truncated: capped.truncated,
|
||||||
async click(input: {
|
stats: {
|
||||||
browserId: string;
|
...rawSnapshot.stats,
|
||||||
page: SnapshotPage;
|
refCount: rawSnapshot.refs.length,
|
||||||
ref: string;
|
textLength: capped.snapshot.length,
|
||||||
}): Promise<BrowserRefActionResult> {
|
},
|
||||||
return this.runRefScript(input, (selector) => buildClickScript(selector));
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fill(input: {
|
async fill(input: {
|
||||||
@@ -73,64 +95,7 @@ export class BrowserSnapshotEngine {
|
|||||||
ref: string;
|
ref: string;
|
||||||
value: string;
|
value: string;
|
||||||
}): Promise<BrowserRefActionResult> {
|
}): Promise<BrowserRefActionResult> {
|
||||||
return this.runRefScript(input, (selector) => buildFillScript(selector, input.value));
|
return this.runRefScript(input, (ref) => buildFillScript(ref, input.value));
|
||||||
}
|
|
||||||
|
|
||||||
async typeText(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref?: string;
|
|
||||||
text: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
const selector = this.resolveOptionalRef(input);
|
|
||||||
if (!selector.ok) {
|
|
||||||
return selector;
|
|
||||||
}
|
|
||||||
const result = await input.page.executeJavaScript(
|
|
||||||
buildTypeScript(selector.selector, input.text),
|
|
||||||
);
|
|
||||||
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async keypress(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref?: string;
|
|
||||||
key: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
const selector = this.resolveOptionalRef(input);
|
|
||||||
if (!selector.ok) {
|
|
||||||
return selector;
|
|
||||||
}
|
|
||||||
const result = await input.page.executeJavaScript(
|
|
||||||
buildKeypressScript(selector.selector, input.key),
|
|
||||||
);
|
|
||||||
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async focus(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
return this.runRefScript(input, (selector) => buildFocusScript(selector));
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
return this.runRefScript(input, (selector) => buildClearScript(selector));
|
|
||||||
}
|
|
||||||
|
|
||||||
async check(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
checked: boolean;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
return this.runRefScript(input, (selector) => buildCheckScript(selector, input.checked));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async select(input: {
|
async select(input: {
|
||||||
@@ -139,123 +104,214 @@ export class BrowserSnapshotEngine {
|
|||||||
ref: string;
|
ref: string;
|
||||||
value: string;
|
value: string;
|
||||||
}): Promise<BrowserRefActionResult> {
|
}): Promise<BrowserRefActionResult> {
|
||||||
return this.runRefScript(input, (selector) => buildSelectScript(selector, input.value));
|
return this.runRefScript(input, (ref) => buildSelectScript(ref, input.value));
|
||||||
}
|
|
||||||
|
|
||||||
async hover(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
return this.runRefScript(input, (selector) => buildHoverScript(selector));
|
|
||||||
}
|
|
||||||
|
|
||||||
async drag(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
sourceRef: string;
|
|
||||||
targetRef: string;
|
|
||||||
}): Promise<BrowserRefActionResult> {
|
|
||||||
const source = this.resolveRef({
|
|
||||||
browserId: input.browserId,
|
|
||||||
page: input.page,
|
|
||||||
ref: input.sourceRef,
|
|
||||||
});
|
|
||||||
if (!source.ok) {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
const target = this.resolveRef({
|
|
||||||
browserId: input.browserId,
|
|
||||||
page: input.page,
|
|
||||||
ref: input.targetRef,
|
|
||||||
});
|
|
||||||
if (!target.ok) {
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
const result = await input.page.executeJavaScript(
|
|
||||||
buildDragScript(source.element.selector, target.element.selector),
|
|
||||||
);
|
|
||||||
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBrowser(browserId: string): void {
|
clearBrowser(browserId: string): void {
|
||||||
this.statesByBrowserId.delete(browserId);
|
this.statesByBrowserId.delete(browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectorForRef(input: {
|
runtimeElementExpression(input: { browserId: string; ref: string }): string | BrowserRefFailure {
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
}): { ok: true; selector: string } | BrowserRefFailure {
|
|
||||||
const resolved = this.resolveRef(input);
|
const resolved = this.resolveRef(input);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
return { ok: true, selector: resolved.element.selector };
|
return buildRuntimeElementExpression(resolved.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runRefScript(
|
private async runRefScript(
|
||||||
input: { browserId: string; page: SnapshotPage; ref: string },
|
input: { browserId: string; page: SnapshotPage; ref: string },
|
||||||
buildScript: (selector: string) => string,
|
buildScript: (ref: BrowserRefMetadata) => string,
|
||||||
): Promise<BrowserRefActionResult> {
|
): Promise<BrowserRefActionResult> {
|
||||||
const resolved = this.resolveRef(input);
|
const resolved = this.resolveRef(input);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
const result = await input.page.executeJavaScript(buildScript(resolved.element.selector));
|
const result = await input.page.executeJavaScript(buildScript(resolved.metadata));
|
||||||
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
|
return readActionResult(result, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveRef(input: {
|
private resolveRef(input: { browserId: string; ref: string }): BrowserRefResolveResult {
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref: string;
|
|
||||||
}): BrowserRefResolveResult {
|
|
||||||
const state = this.statesByBrowserId.get(input.browserId);
|
const state = this.statesByBrowserId.get(input.browserId);
|
||||||
if (!state || state.url !== input.page.getURL()) {
|
if (!state) {
|
||||||
return { ok: false, reason: "stale_ref" };
|
return { ok: false, reason: "stale_ref" };
|
||||||
}
|
}
|
||||||
const element = state.refs.get(input.ref);
|
const metadata = state.refs.get(input.ref);
|
||||||
if (!element) {
|
if (!metadata) {
|
||||||
return { ok: false, reason: "missing_ref" };
|
return { ok: false, reason: "missing_ref" };
|
||||||
}
|
}
|
||||||
return { ok: true, element };
|
return { ok: true, metadata };
|
||||||
}
|
|
||||||
|
|
||||||
private resolveOptionalRef(input: {
|
|
||||||
browserId: string;
|
|
||||||
page: SnapshotPage;
|
|
||||||
ref?: string;
|
|
||||||
}): { ok: true; selector: string | undefined } | BrowserRefFailure {
|
|
||||||
if (!input.ref) {
|
|
||||||
return { ok: true, selector: undefined };
|
|
||||||
}
|
|
||||||
const resolved = this.resolveRef({
|
|
||||||
browserId: input.browserId,
|
|
||||||
page: input.page,
|
|
||||||
ref: input.ref,
|
|
||||||
});
|
|
||||||
if (!resolved.ok) {
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
return { ok: true, selector: resolved.element.selector };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClickScript(selector: string): string {
|
function readActionResult(value: unknown, staleWhenFalse: boolean): BrowserRefActionResult {
|
||||||
return String.raw`(() => {
|
if (value === false && staleWhenFalse) {
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
return { ok: false, reason: "stale_ref" };
|
||||||
if (!element) return false;
|
}
|
||||||
element.scrollIntoView({ block: 'center', inline: 'center' });
|
if (isActionFailure(value)) {
|
||||||
element.click();
|
return { ok: false, reason: value.reason };
|
||||||
return true;
|
}
|
||||||
})()`;
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFillScript(selector: string, value: string): string {
|
function isActionFailure(value: unknown): value is BrowserRefFailure {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const reason = (value as Record<string, unknown>).reason;
|
||||||
|
return reason === "stale_ref" || reason === "missing_ref";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAriaSnapshot(value: unknown): RawAriaSnapshot {
|
||||||
|
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return emptySnapshot();
|
||||||
|
}
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
if (record.marker !== ARIA_SNAPSHOT_SCRIPT_MARKER) {
|
||||||
|
return emptySnapshot();
|
||||||
|
}
|
||||||
|
const root = parseSnapshotNode(record.root);
|
||||||
|
return {
|
||||||
|
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
|
||||||
|
root: root ?? emptySnapshot().root,
|
||||||
|
refs: parseRefs(record.refs),
|
||||||
|
truncated: record.truncated === true,
|
||||||
|
stats: parseStats(record.stats),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySnapshot(): RawAriaSnapshot {
|
||||||
|
return {
|
||||||
|
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
|
||||||
|
root: { kind: "role", role: "document", name: "", tagName: "document", children: [] },
|
||||||
|
refs: [],
|
||||||
|
truncated: false,
|
||||||
|
stats: { nodeCount: 0, refCount: 0, textLength: 0, iframeCount: 0, maxDepth: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshotNode(value: unknown): SnapshotNode | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const kind = record.kind;
|
||||||
|
if (kind !== "role" && kind !== "text" && kind !== "group") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
...(readString(record.role) ? { role: readString(record.role) ?? undefined } : {}),
|
||||||
|
...(readString(record.name) ? { name: readString(record.name) ?? undefined } : {}),
|
||||||
|
...(readString(record.text) ? { text: readString(record.text) ?? undefined } : {}),
|
||||||
|
...(readString(record.tagName) ? { tagName: readString(record.tagName) ?? undefined } : {}),
|
||||||
|
...(readString(record.ref) ? { ref: readString(record.ref) ?? undefined } : {}),
|
||||||
|
...(parseFingerprint(record.fingerprint)
|
||||||
|
? { fingerprint: parseFingerprint(record.fingerprint) ?? undefined }
|
||||||
|
: {}),
|
||||||
|
attributes: readStringArray(record.attributes),
|
||||||
|
children: Array.isArray(record.children)
|
||||||
|
? record.children.flatMap((child): SnapshotNode[] => {
|
||||||
|
const parsed = parseSnapshotNode(child);
|
||||||
|
return parsed ? [parsed] : [];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRefs(value: unknown): BrowserRefMetadata[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.flatMap((item): BrowserRefMetadata[] => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const ref = readString(record.ref);
|
||||||
|
const fingerprint = parseFingerprint(record.fingerprint);
|
||||||
|
return ref && fingerprint ? [{ ref, fingerprint }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFingerprint(value: unknown): BrowserRefFingerprint | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const role = readString(record.role);
|
||||||
|
const name = readString(record.name);
|
||||||
|
const tagName = readString(record.tagName);
|
||||||
|
const type = readString(record.type);
|
||||||
|
const ariaLabel = readString(record.ariaLabel);
|
||||||
|
if (role === null || name === null || tagName === null || type === null || ariaLabel === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { role, name, tagName, type, ariaLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStats(value: unknown): BrowserAriaSnapshotStats {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { nodeCount: 0, refCount: 0, textLength: 0 };
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
nodeCount: readNumber(record.nodeCount) ?? 0,
|
||||||
|
refCount: readNumber(record.refCount) ?? 0,
|
||||||
|
textLength: readNumber(record.textLength) ?? 0,
|
||||||
|
...(readNumber(record.iframeCount) !== null
|
||||||
|
? { iframeCount: readNumber(record.iframeCount) ?? undefined }
|
||||||
|
: {}),
|
||||||
|
...(readNumber(record.maxDepth) !== null
|
||||||
|
? { maxDepth: readNumber(record.maxDepth) ?? undefined }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshot(root: SnapshotNode): string {
|
||||||
|
const lines = renderNode(root, 0);
|
||||||
|
return lines.length > 0 ? lines.join("\n") : "- document";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: SnapshotNode, depth: number): string[] {
|
||||||
|
if (node.kind === "group") {
|
||||||
|
return (node.children ?? []).flatMap((child) => renderNode(child, depth));
|
||||||
|
}
|
||||||
|
const indent = " ".repeat(depth);
|
||||||
|
if (node.kind === "text") {
|
||||||
|
return [`${indent}- text: ${JSON.stringify(node.text ?? "")}`];
|
||||||
|
}
|
||||||
|
const attrs = [...(node.attributes ?? [])];
|
||||||
|
if (node.ref) {
|
||||||
|
attrs.push(`ref=${node.ref}`);
|
||||||
|
}
|
||||||
|
const suffix = attrs.length > 0 ? ` [${attrs.join(" ")}]` : "";
|
||||||
|
const ownLine = `${indent}- ${node.role ?? "generic"}${node.name ? ` ${JSON.stringify(node.name)}` : ""}${suffix}`;
|
||||||
|
const childLines = (node.children ?? []).flatMap((child) => renderNode(child, depth + 1));
|
||||||
|
return [ownLine, ...childLines];
|
||||||
|
}
|
||||||
|
|
||||||
|
function capRenderedSnapshot(
|
||||||
|
snapshot: string,
|
||||||
|
alreadyTruncated: boolean,
|
||||||
|
): { snapshot: string; truncated: boolean } {
|
||||||
|
if (snapshot.length <= MAX_RENDERED_TEXT_LENGTH && !alreadyTruncated) {
|
||||||
|
return { snapshot, truncated: false };
|
||||||
|
}
|
||||||
|
if (snapshot.length + 1 + TRUNCATION_MARKER.length <= MAX_RENDERED_TEXT_LENGTH) {
|
||||||
|
return { snapshot: `${snapshot}\n${TRUNCATION_MARKER}`, truncated: true };
|
||||||
|
}
|
||||||
|
const availableLength = MAX_RENDERED_TEXT_LENGTH - TRUNCATION_MARKER.length - 1;
|
||||||
|
const capped = snapshot.slice(0, Math.max(0, availableLength)).replace(/\n[^\n]*$/, "");
|
||||||
|
return { snapshot: `${capped}\n${TRUNCATION_MARKER}`, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
|
||||||
return String.raw`(() => {
|
return String.raw`(() => {
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
const resolved = ${buildResolveExpression(metadata)};
|
||||||
if (!element) return false;
|
if (!resolved.ok) return resolved;
|
||||||
|
const element = resolved.element;
|
||||||
element.scrollIntoView({ block: 'center', inline: 'center' });
|
element.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
element.focus();
|
element.focus();
|
||||||
const nextValue = ${JSON.stringify(value)};
|
const nextValue = ${JSON.stringify(value)};
|
||||||
@@ -263,102 +319,19 @@ function buildFillScript(selector: string, value: string): string {
|
|||||||
element.value = nextValue;
|
element.value = nextValue;
|
||||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
return true;
|
return { ok: true };
|
||||||
}
|
}
|
||||||
element.textContent = nextValue;
|
element.textContent = nextValue;
|
||||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue }));
|
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue }));
|
||||||
return true;
|
return { ok: true };
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTypeScript(selector: string | undefined, text: string): string {
|
function buildSelectScript(metadata: BrowserRefMetadata, value: string): string {
|
||||||
return String.raw`(() => {
|
return String.raw`(() => {
|
||||||
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
|
const resolved = ${buildResolveExpression(metadata)};
|
||||||
if (!element) return false;
|
if (!resolved.ok) return resolved;
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
const element = resolved.element;
|
||||||
element.focus?.();
|
|
||||||
const text = ${JSON.stringify(text)};
|
|
||||||
if ('value' in element) {
|
|
||||||
element.value = String(element.value || '') + text;
|
|
||||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
|
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
element.textContent = String(element.textContent || '') + text;
|
|
||||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
|
|
||||||
return true;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKeypressScript(selector: string | undefined, key: string): string {
|
|
||||||
return String.raw`(() => {
|
|
||||||
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
|
|
||||||
if (!element) return false;
|
|
||||||
element.focus?.();
|
|
||||||
const key = ${JSON.stringify(key)};
|
|
||||||
const eventInit = { bubbles: true, cancelable: true, key };
|
|
||||||
element.dispatchEvent(new KeyboardEvent('keydown', eventInit));
|
|
||||||
element.dispatchEvent(new KeyboardEvent('keypress', eventInit));
|
|
||||||
element.dispatchEvent(new KeyboardEvent('keyup', eventInit));
|
|
||||||
return true;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFocusScript(selector: string): string {
|
|
||||||
return String.raw`(() => {
|
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
|
||||||
if (!element) return false;
|
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
|
||||||
element.focus?.();
|
|
||||||
return document.activeElement === element;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClearScript(selector: string): string {
|
|
||||||
return String.raw`(() => {
|
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
|
||||||
if (!element) return false;
|
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
|
||||||
element.focus?.();
|
|
||||||
if ('value' in element) {
|
|
||||||
element.value = '';
|
|
||||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
|
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
element.textContent = '';
|
|
||||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
|
|
||||||
return true;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCheckScript(selector: string, checked: boolean): string {
|
|
||||||
return String.raw`(() => {
|
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
|
||||||
if (!element) return false;
|
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
|
||||||
element.focus?.();
|
|
||||||
const nextChecked = ${JSON.stringify(checked)};
|
|
||||||
if ('checked' in element) {
|
|
||||||
element.checked = nextChecked;
|
|
||||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'radio') {
|
|
||||||
element.setAttribute('aria-checked', String(nextChecked));
|
|
||||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSelectScript(selector: string, value: string): string {
|
|
||||||
return String.raw`(() => {
|
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
|
||||||
if (!element) return false;
|
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
||||||
element.focus?.();
|
element.focus?.();
|
||||||
const nextValue = ${JSON.stringify(value)};
|
const nextValue = ${JSON.stringify(value)};
|
||||||
@@ -366,205 +339,34 @@ function buildSelectScript(selector: string, value: string): string {
|
|||||||
element.value = nextValue;
|
element.value = nextValue;
|
||||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
return true;
|
return { ok: true };
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHoverScript(selector: string): string {
|
function buildRuntimeElementExpression(metadata: BrowserRefMetadata): string {
|
||||||
return String.raw`(() => {
|
return String.raw`(() => {
|
||||||
const element = document.querySelector(${JSON.stringify(selector)});
|
const resolved = ${buildResolveExpression(metadata)};
|
||||||
if (!element) return false;
|
if (!resolved.ok) return null;
|
||||||
element.scrollIntoView?.({ block: 'center', inline: 'center' });
|
return resolved.element;
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const eventInit = {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
clientX: rect.left + rect.width / 2,
|
|
||||||
clientY: rect.top + rect.height / 2,
|
|
||||||
screenX: window.screenX + rect.left + rect.width / 2,
|
|
||||||
screenY: window.screenY + rect.top + rect.height / 2,
|
|
||||||
view: window,
|
|
||||||
};
|
|
||||||
element.dispatchEvent(new MouseEvent('mouseover', eventInit));
|
|
||||||
element.dispatchEvent(new MouseEvent('mouseenter', eventInit));
|
|
||||||
element.dispatchEvent(new MouseEvent('mousemove', eventInit));
|
|
||||||
return true;
|
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDragScript(sourceSelector: string, targetSelector: string): string {
|
function buildResolveExpression(metadata: BrowserRefMetadata): string {
|
||||||
return String.raw`(() => {
|
return `window.__PASEO_BROWSER_AUTOMATION__?.resolve(${JSON.stringify(metadata.ref)}, ${JSON.stringify(metadata.fingerprint)}) ?? { ok: false, reason: 'stale_ref' }`;
|
||||||
const source = document.querySelector(${JSON.stringify(sourceSelector)});
|
|
||||||
const target = document.querySelector(${JSON.stringify(targetSelector)});
|
|
||||||
if (!source || !target) return false;
|
|
||||||
source.scrollIntoView?.({ block: 'center', inline: 'center' });
|
|
||||||
target.scrollIntoView?.({ block: 'center', inline: 'center' });
|
|
||||||
const data = new DataTransfer();
|
|
||||||
const sourceRect = source.getBoundingClientRect();
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
|
||||||
function eventInit(rect) {
|
|
||||||
return {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
clientX: rect.left + rect.width / 2,
|
|
||||||
clientY: rect.top + rect.height / 2,
|
|
||||||
screenX: window.screenX + rect.left + rect.width / 2,
|
|
||||||
screenY: window.screenY + rect.top + rect.height / 2,
|
|
||||||
dataTransfer: data,
|
|
||||||
view: window,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
source.dispatchEvent(new MouseEvent('mousedown', eventInit(sourceRect)));
|
|
||||||
source.dispatchEvent(new DragEvent('dragstart', eventInit(sourceRect)));
|
|
||||||
target.dispatchEvent(new DragEvent('dragenter', eventInit(targetRect)));
|
|
||||||
target.dispatchEvent(new DragEvent('dragover', eventInit(targetRect)));
|
|
||||||
target.dispatchEvent(new DragEvent('drop', eventInit(targetRect)));
|
|
||||||
source.dispatchEvent(new DragEvent('dragend', eventInit(sourceRect)));
|
|
||||||
target.dispatchEvent(new MouseEvent('mouseup', eventInit(targetRect)));
|
|
||||||
return true;
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRawSnapshotElements(value: unknown): RawSnapshotElement[] {
|
|
||||||
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return parsed.flatMap((item): RawSnapshotElement[] => {
|
|
||||||
if (!item || typeof item !== "object") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
const selector = readString(record.selector);
|
|
||||||
if (!selector) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
role: readString(record.role) || "generic",
|
|
||||||
tagName: (readString(record.tagName) || "element").toLowerCase(),
|
|
||||||
text: readString(record.text) || "",
|
|
||||||
selector,
|
|
||||||
attributes: readAttributes(record.attributes),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readString(value: unknown): string | null {
|
function readString(value: unknown): string | null {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAttributes(value: unknown): Record<string, string> {
|
function readStringArray(value: unknown): string[] {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
return Array.isArray(value)
|
||||||
return {};
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
}
|
: [];
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const [key, attributeValue] of Object.entries(value)) {
|
|
||||||
if (typeof attributeValue === "string") {
|
|
||||||
result[key] = attributeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_SCRIPT = String.raw`(() => {
|
function readNumber(value: unknown): number | null {
|
||||||
const MAX_ELEMENTS = 200;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
const CANDIDATE_SELECTOR = [
|
}
|
||||||
'a[href]',
|
|
||||||
'button',
|
|
||||||
'input',
|
|
||||||
'textarea',
|
|
||||||
'select',
|
|
||||||
'summary',
|
|
||||||
'[role]',
|
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
|
||||||
'[contenteditable=""]',
|
|
||||||
'[contenteditable="true"]'
|
|
||||||
].join(',');
|
|
||||||
|
|
||||||
function cssEscape(value) {
|
|
||||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
|
||||||
return window.CSS.escape(value);
|
|
||||||
}
|
|
||||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVisible(element) {
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleFor(element) {
|
|
||||||
const explicit = element.getAttribute('role');
|
|
||||||
if (explicit) return explicit;
|
|
||||||
const tag = element.tagName.toLowerCase();
|
|
||||||
if (tag === 'a') return 'link';
|
|
||||||
if (tag === 'button') return 'button';
|
|
||||||
if (tag === 'select') return 'combobox';
|
|
||||||
if (tag === 'textarea') return 'textbox';
|
|
||||||
if (tag === 'summary') return 'button';
|
|
||||||
if (tag === 'input') {
|
|
||||||
const type = (element.getAttribute('type') || 'text').toLowerCase();
|
|
||||||
if (type === 'checkbox') return 'checkbox';
|
|
||||||
if (type === 'radio') return 'radio';
|
|
||||||
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
|
||||||
return 'textbox';
|
|
||||||
}
|
|
||||||
return 'generic';
|
|
||||||
}
|
|
||||||
|
|
||||||
function textFor(element) {
|
|
||||||
const tag = element.tagName.toLowerCase();
|
|
||||||
const pieces = [
|
|
||||||
element.getAttribute('aria-label'),
|
|
||||||
element.getAttribute('alt'),
|
|
||||||
element.getAttribute('title'),
|
|
||||||
tag === 'input' ? element.getAttribute('placeholder') : null,
|
|
||||||
tag === 'input' || tag === 'textarea' ? element.value : null,
|
|
||||||
element.innerText,
|
|
||||||
element.textContent
|
|
||||||
];
|
|
||||||
const text = pieces.find((piece) => typeof piece === 'string' && piece.trim().length > 0);
|
|
||||||
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectorFor(element) {
|
|
||||||
if (element.id) return '#' + cssEscape(element.id);
|
|
||||||
const parts = [];
|
|
||||||
let current = element;
|
|
||||||
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
|
|
||||||
const tag = current.tagName.toLowerCase();
|
|
||||||
const parent = current.parentElement;
|
|
||||||
if (!parent) break;
|
|
||||||
const siblings = Array.from(parent.children).filter((sibling) => sibling.tagName === current.tagName);
|
|
||||||
const index = siblings.indexOf(current) + 1;
|
|
||||||
parts.unshift(siblings.length > 1 ? tag + ':nth-of-type(' + index + ')' : tag);
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts.join(' > ') : element.tagName.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(Array.from(document.querySelectorAll(CANDIDATE_SELECTOR))
|
|
||||||
.filter(isVisible)
|
|
||||||
.slice(0, MAX_ELEMENTS)
|
|
||||||
.map((element) => ({
|
|
||||||
role: roleFor(element),
|
|
||||||
tagName: element.tagName.toLowerCase(),
|
|
||||||
text: textFor(element),
|
|
||||||
selector: selectorFor(element),
|
|
||||||
attributes: {
|
|
||||||
...(element.id ? { id: element.id } : {}),
|
|
||||||
...(element.getAttribute('name') ? { name: element.getAttribute('name') } : {}),
|
|
||||||
...(element.getAttribute('type') ? { type: element.getAttribute('type') } : {}),
|
|
||||||
...(element.getAttribute('href') ? { href: element.getAttribute('href') } : {}),
|
|
||||||
...(element.getAttribute('aria-label') ? { 'aria-label': element.getAttribute('aria-label') } : {})
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
})()`;
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { dispatchTrustedKey } from "./trusted-input.js";
|
||||||
|
|
||||||
|
describe("trusted browser input", () => {
|
||||||
|
test("Space dispatches a real space key event", async () => {
|
||||||
|
const commands: Array<{ command: string; params?: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
await dispatchTrustedKey(async (command, params) => {
|
||||||
|
commands.push({ command, ...(params ? { params } : {}) });
|
||||||
|
return {};
|
||||||
|
}, "Space");
|
||||||
|
|
||||||
|
expect(commands).toEqual([
|
||||||
|
{
|
||||||
|
command: "Input.dispatchKeyEvent",
|
||||||
|
params: {
|
||||||
|
type: "keyDown",
|
||||||
|
key: " ",
|
||||||
|
code: "Space",
|
||||||
|
windowsVirtualKeyCode: 32,
|
||||||
|
nativeVirtualKeyCode: 32,
|
||||||
|
text: " ",
|
||||||
|
unmodifiedText: " ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "Input.dispatchKeyEvent",
|
||||||
|
params: {
|
||||||
|
type: "keyUp",
|
||||||
|
key: " ",
|
||||||
|
code: "Space",
|
||||||
|
windowsVirtualKeyCode: 32,
|
||||||
|
nativeVirtualKeyCode: 32,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { ActionablePoint } from "./actionability.js";
|
||||||
|
import type { CdpCommandSender } from "./cdp-session-queue.js";
|
||||||
|
|
||||||
|
export type MouseButton = "left" | "right" | "middle";
|
||||||
|
export type InputModifier = "Alt" | "Control" | "Meta" | "Shift";
|
||||||
|
|
||||||
|
export interface ClickInputOptions {
|
||||||
|
button?: MouseButton;
|
||||||
|
doubleClick?: boolean;
|
||||||
|
modifiers?: InputModifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODIFIER_MASKS: Record<InputModifier, number> = {
|
||||||
|
Alt: 1,
|
||||||
|
Control: 2,
|
||||||
|
Meta: 4,
|
||||||
|
Shift: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPECIAL_KEY_DEFINITIONS: Record<
|
||||||
|
string,
|
||||||
|
{ key: string; code: string; windowsVirtualKeyCode: number; text?: string }
|
||||||
|
> = {
|
||||||
|
Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
|
||||||
|
Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
|
||||||
|
Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
|
||||||
|
Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
|
||||||
|
Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
|
||||||
|
Delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
|
||||||
|
ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
|
||||||
|
ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
|
||||||
|
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
|
||||||
|
ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
|
||||||
|
Home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
|
||||||
|
End: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
|
||||||
|
PageUp: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
|
||||||
|
PageDown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function dispatchTrustedClick(
|
||||||
|
send: CdpCommandSender,
|
||||||
|
point: ActionablePoint,
|
||||||
|
options: ClickInputOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const button = options.button ?? "left";
|
||||||
|
const modifiers = modifierMask(options.modifiers);
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button: "none",
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
|
if (options.doubleClick) {
|
||||||
|
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
|
||||||
|
await dispatchTrustedMouseClick(send, point, button, modifiers, 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchTrustedMouseClick(
|
||||||
|
send: CdpCommandSender,
|
||||||
|
point: ActionablePoint,
|
||||||
|
button: MouseButton,
|
||||||
|
modifiers: number,
|
||||||
|
clickCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mousePressed",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button,
|
||||||
|
buttons: mouseButtonMask(button),
|
||||||
|
clickCount,
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseReleased",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button,
|
||||||
|
buttons: 0,
|
||||||
|
clickCount,
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchTrustedHover(
|
||||||
|
send: CdpCommandSender,
|
||||||
|
point: ActionablePoint,
|
||||||
|
): Promise<void> {
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchTrustedDrag(
|
||||||
|
send: CdpCommandSender,
|
||||||
|
source: ActionablePoint,
|
||||||
|
target: ActionablePoint,
|
||||||
|
): Promise<void> {
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: source.x,
|
||||||
|
y: source.y,
|
||||||
|
button: "none",
|
||||||
|
});
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mousePressed",
|
||||||
|
x: source.x,
|
||||||
|
y: source.y,
|
||||||
|
button: "left",
|
||||||
|
buttons: 1,
|
||||||
|
clickCount: 1,
|
||||||
|
});
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: (source.x + target.x) / 2,
|
||||||
|
y: (source.y + target.y) / 2,
|
||||||
|
button: "left",
|
||||||
|
buttons: 1,
|
||||||
|
});
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
button: "left",
|
||||||
|
buttons: 1,
|
||||||
|
});
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseReleased",
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
button: "left",
|
||||||
|
buttons: 0,
|
||||||
|
clickCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchTrustedScroll(
|
||||||
|
send: CdpCommandSender,
|
||||||
|
point: ActionablePoint,
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await send("Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseWheel",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise<void> {
|
||||||
|
if (text.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await send("Input.insertText", { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchTrustedKey(send: CdpCommandSender, key: string): Promise<void> {
|
||||||
|
const definition = keyDefinition(key);
|
||||||
|
await send("Input.dispatchKeyEvent", {
|
||||||
|
type: "keyDown",
|
||||||
|
key: definition.key,
|
||||||
|
code: definition.code,
|
||||||
|
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||||
|
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||||
|
...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
|
||||||
|
});
|
||||||
|
await send("Input.dispatchKeyEvent", {
|
||||||
|
type: "keyUp",
|
||||||
|
key: definition.key,
|
||||||
|
code: definition.code,
|
||||||
|
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||||
|
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyDefinition(key: string): {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
windowsVirtualKeyCode: number;
|
||||||
|
text?: string;
|
||||||
|
} {
|
||||||
|
const special = SPECIAL_KEY_DEFINITIONS[key];
|
||||||
|
if (special) {
|
||||||
|
return special;
|
||||||
|
}
|
||||||
|
const text = key.length === 1 ? key : "";
|
||||||
|
const upper = text.toUpperCase();
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
code: upper ? `Key${upper}` : key,
|
||||||
|
windowsVirtualKeyCode: upper ? upper.charCodeAt(0) : 0,
|
||||||
|
...(text ? { text, unmodifiedText: text } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifierMask(modifiers: InputModifier[] | undefined): number {
|
||||||
|
return (modifiers ?? []).reduce((mask, modifier) => mask | MODIFIER_MASKS[modifier], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseButtonMask(button: MouseButton): number {
|
||||||
|
if (button === "right") {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (button === "middle") {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js";
|
||||||
|
|
||||||
|
class FakeRegisteredWebContents {
|
||||||
|
public readonly backgroundThrottlingCalls: boolean[] = [];
|
||||||
|
private destroyedListener: (() => void) | null = null;
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
public constructor(public readonly id: number) {}
|
||||||
|
|
||||||
|
public isDestroyed(): boolean {
|
||||||
|
return this.destroyed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBackgroundThrottling(allowed: boolean): void {
|
||||||
|
this.backgroundThrottlingCalls.push(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public once(event: "destroyed", listener: () => void): void {
|
||||||
|
expect(event).toBe("destroyed");
|
||||||
|
this.destroyedListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.destroyedListener?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("registerPaseoBrowserWebContents", () => {
|
||||||
|
test("disables guest background throttling once when the webview is registered", () => {
|
||||||
|
const contents = new FakeRegisteredWebContents(9001);
|
||||||
|
|
||||||
|
registerPaseoBrowserWebContents(contents, "browser-throttle");
|
||||||
|
|
||||||
|
expect(contents.backgroundThrottlingCalls).toEqual([false]);
|
||||||
|
expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle");
|
||||||
|
|
||||||
|
contents.destroy();
|
||||||
|
|
||||||
|
expect(getPaseoBrowserIdForWebContents(contents)).toBeNull();
|
||||||
|
expect(contents.backgroundThrottlingCalls).toEqual([false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,16 @@ export type { BrowserWorkspaceRegistration };
|
|||||||
|
|
||||||
const browserRegistry = new PaseoBrowserWebviewRegistry();
|
const browserRegistry = new PaseoBrowserWebviewRegistry();
|
||||||
|
|
||||||
|
interface BrowserWebContentsIdentity {
|
||||||
|
readonly id: number;
|
||||||
|
isDestroyed(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity {
|
||||||
|
setBackgroundThrottling(allowed: boolean): void;
|
||||||
|
once(event: "destroyed", listener: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
||||||
const prefix = "persist:paseo-browser-";
|
const prefix = "persist:paseo-browser-";
|
||||||
if (!partition?.startsWith(prefix)) {
|
if (!partition?.startsWith(prefix)) {
|
||||||
@@ -36,14 +46,20 @@ export function listRegisteredPaseoBrowserIds(): string[] {
|
|||||||
.filter((browserId) => getPaseoBrowserWebContents(browserId));
|
.filter((browserId) => getPaseoBrowserWebContents(browserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
|
export function registerPaseoBrowserWebContents(
|
||||||
|
contents: RegisteredBrowserWebContents,
|
||||||
|
browserId: string,
|
||||||
|
): void {
|
||||||
|
contents.setBackgroundThrottling(false);
|
||||||
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
|
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
|
||||||
contents.once("destroyed", () => {
|
contents.once("destroyed", () => {
|
||||||
browserRegistry.unregisterWebContents(contents.id);
|
browserRegistry.unregisterWebContents(contents.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPaseoBrowserIdForWebContents(contents: WebContents | null): string | null {
|
export function getPaseoBrowserIdForWebContents(
|
||||||
|
contents: BrowserWebContentsIdentity | null,
|
||||||
|
): string | null {
|
||||||
if (!contents || contents.isDestroyed()) {
|
if (!contents || contents.isDestroyed()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -54,6 +70,10 @@ export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistratio
|
|||||||
browserRegistry.registerWorkspace(input);
|
browserRegistry.registerWorkspace(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function unregisterPaseoBrowser(browserId: string): void {
|
||||||
|
browserRegistry.unregisterBrowser(browserId);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
|
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
|
||||||
return browserRegistry.getWorkspaceId(browserId);
|
return browserRegistry.getWorkspaceId(browserId);
|
||||||
}
|
}
|
||||||
@@ -75,17 +95,6 @@ export function getWorkspaceActivePaseoBrowserId(workspaceId: string): string |
|
|||||||
return browserRegistry.getWorkspaceActiveBrowserId(workspaceId);
|
return browserRegistry.getWorkspaceActiveBrowserId(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAgentActivePaseoBrowserId(input: {
|
|
||||||
agentId: string;
|
|
||||||
browserId: string | null;
|
|
||||||
}): void {
|
|
||||||
browserRegistry.setAgentActiveBrowser(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgentActivePaseoBrowserId(agentId: string): string | null {
|
|
||||||
return browserRegistry.getAgentActiveBrowserId(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
|
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
|
||||||
const contentsId = browserRegistry.getWebContentsIdForBrowser(browserId);
|
const contentsId = browserRegistry.getWebContentsIdForBrowser(browserId);
|
||||||
if (contentsId === null) {
|
if (contentsId === null) {
|
||||||
@@ -99,16 +108,6 @@ export function getPaseoBrowserWebContents(browserId: string): WebContents | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceActivePaseoBrowserWebContents(workspaceId: string): WebContents | null {
|
|
||||||
const activeBrowserId = getWorkspaceActivePaseoBrowserId(workspaceId);
|
|
||||||
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgentActivePaseoBrowserWebContents(agentId: string): WebContents | null {
|
|
||||||
const activeBrowserId = getAgentActivePaseoBrowserId(agentId);
|
|
||||||
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMostRecentWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
|
export function getMostRecentWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
|
||||||
const browserId = browserRegistry.getMostRecentWorkspaceActiveBrowserId();
|
const browserId = browserRegistry.getMostRecentWorkspaceActiveBrowserId();
|
||||||
return browserId ? getPaseoBrowserWebContents(browserId) : null;
|
return browserId ? getPaseoBrowserWebContents(browserId) : null;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user