mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
88 Commits
feat/daemo
...
lenient-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eec33773a | ||
|
|
5616c6af6a | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 | ||
|
|
2263469342 | ||
|
|
5a0ea3385e | ||
|
|
3708eddbcd | ||
|
|
f41dde8c72 | ||
|
|
d3e8f77914 | ||
|
|
48d07dedb5 | ||
|
|
31e9a210d0 | ||
|
|
bf5e3b47e7 | ||
|
|
b8b66816ca | ||
|
|
2b46caa20e | ||
|
|
20385bdb50 | ||
|
|
db03b1f3fd | ||
|
|
cb486c3a5a | ||
|
|
7dad7a377c | ||
|
|
e32c50f2d7 | ||
|
|
e3eb633582 | ||
|
|
beac4544fd | ||
|
|
c2271ff0ca | ||
|
|
f91806defe | ||
|
|
e9431517a0 | ||
|
|
4e28b0941b | ||
|
|
7c6152663e | ||
|
|
5fc53c576e | ||
|
|
8eb9640f28 | ||
|
|
0798f997c3 | ||
|
|
68a169b29d | ||
|
|
b571874852 | ||
|
|
ad803005c8 | ||
|
|
86775c18f3 | ||
|
|
cf2fccd01d | ||
|
|
811f52395e | ||
|
|
df36a958f8 | ||
|
|
111fdb81fd | ||
|
|
efd7ab3420 | ||
|
|
f94ec0e430 | ||
|
|
0614618d2f | ||
|
|
f31bc2b8b7 | ||
|
|
31d14ec8a7 | ||
|
|
a1afdfee51 | ||
|
|
b5559dc1de | ||
|
|
85acaceb16 | ||
|
|
e63a971968 | ||
|
|
b613bea9f6 | ||
|
|
57800a0f17 | ||
|
|
0d1c8db87d | ||
|
|
6a23bbd121 | ||
|
|
3288e1cfb9 | ||
|
|
cb212b4e5c | ||
|
|
4968969c87 | ||
|
|
96573d0bcc | ||
|
|
9b6aa99396 | ||
|
|
276c1f48f1 | ||
|
|
821d194fbe | ||
|
|
ab3ed56513 | ||
|
|
62bdb52eaa | ||
|
|
d7b24ab124 | ||
|
|
c7e27fdd5b | ||
|
|
fc52b2812c | ||
|
|
47a71631e8 | ||
|
|
696a9f0119 | ||
|
|
26a8a04651 | ||
|
|
a908384e5a | ||
|
|
24a1f1b8b9 | ||
|
|
65f1143e6a | ||
|
|
f275275074 | ||
|
|
5ef118ea33 | ||
|
|
8228bafc5d | ||
|
|
d6b946720e | ||
|
|
07680cdd69 | ||
|
|
1ce00dca1f | ||
|
|
28ea0914c5 | ||
|
|
6277ba1ff9 | ||
|
|
85322c5968 | ||
|
|
daf7042cd2 | ||
|
|
99643ad085 | ||
|
|
40265782d4 | ||
|
|
9ad49fca92 | ||
|
|
3fbd82664d | ||
|
|
14a91d889c | ||
|
|
2c9db5279c | ||
|
|
28c5e55bd9 | ||
|
|
862154541a |
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
.git
|
||||
.dev
|
||||
.playwright-mcp
|
||||
.paseo
|
||||
.tasks
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/.cache
|
||||
**/.expo
|
||||
**/test-results
|
||||
**/*.tsbuildinfo
|
||||
artifacts
|
||||
packages/app/android
|
||||
packages/desktop/release
|
||||
plan.*.log
|
||||
*.log
|
||||
CLAUDE.local.md
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -241,6 +241,11 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -280,7 +285,7 @@ jobs:
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -288,7 +293,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
|
||||
205
.github/workflows/docker.yml
vendored
Normal file
205
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".github/workflows/docker.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Paseo version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
description: "Publish the image to GHCR. Manual publishes require paseo_version."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- id: values
|
||||
env:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
install_version="${REF_NAME#v}"
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
|
||||
echo "::error::paseo_version is required for manual Docker publishes."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
fi
|
||||
|
||||
if [[ "${install_version}" == *-* ]]; then
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
fi
|
||||
|
||||
check_tag="${image}:check-${GITHUB_SHA::12}"
|
||||
publish_tags="${image}:${install_version}"
|
||||
if [[ "${publish_latest}" == "true" ]]; then
|
||||
publish_tags="${publish_tags}"$'\n'"${image}:latest"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.check_tag }}
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
|
||||
publish:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.publish_tags }}
|
||||
push: true
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
|
||||
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -33,6 +33,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
@@ -44,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
@@ -71,6 +73,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
Paseo is an opinionated product maintained by one person right now.
|
||||
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
|
||||
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
## Becoming a maintainer
|
||||
|
||||
Follow these rules if you want your PR to be merged:
|
||||
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
|
||||
|
||||
Here's the progression:
|
||||
|
||||
1. Get involved in the community: answer questions in Discord and on GitHub
|
||||
2. Triage bugs: replicate and help fix them
|
||||
3. Work on maintainer-approved features
|
||||
|
||||
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
|
||||
|
||||
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
|
||||
|
||||
## Pull requests
|
||||
|
||||
✅ Will be accepted
|
||||
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
@@ -18,15 +32,25 @@ Follow these rules if you want your PR to be merged:
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
Your PR will be closed if you do any of these:
|
||||
⛔️ Will be rejected
|
||||
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Add a feature or design change that wasn't discussed first
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
## Requesting features
|
||||
|
||||
If you need a feature implemented, create a Github issue or a thread in Discord.
|
||||
|
||||
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
|
||||
|
||||
## AI assistance
|
||||
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
Using AI to help write code is fine, but you must:
|
||||
|
||||
- Ensure your agents read the docs
|
||||
- Understand the code you submit
|
||||
- Review and test the code yourself
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -88,6 +88,21 @@ For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
### Docker
|
||||
|
||||
Run the Paseo daemon and self-hosted web UI in Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
@@ -154,6 +169,7 @@ npm run typecheck
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ Connected clients are trusted operators of the daemon user. File previews follow
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
`paseo` user by default. Mounted workspaces and credentials are still fully
|
||||
available to anything the agents run inside the container.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
17
docker/Dockerfile.agents.example
Normal file
17
docker/Dockerfile.agents.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Example child image that adds agent CLIs to the official Paseo image.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f docker/Dockerfile.agents.example -t paseo-with-agents .
|
||||
#
|
||||
# Then set `image: paseo-with-agents` in docker/docker-compose.example.yml.
|
||||
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
opencode-ai
|
||||
|
||||
# Leave the image user as root. The base entrypoint prepares mounted volumes,
|
||||
# then drops the daemon and launched agents to the non-root `paseo` user.
|
||||
30
docker/README.md
Normal file
30
docker/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Paseo Docker Image
|
||||
|
||||
This directory contains the official Paseo daemon image.
|
||||
|
||||
The image runs the daemon headless and serves the bundled web UI from the same
|
||||
HTTP origin. Start it, then open the daemon URL in a browser.
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open `http://localhost:6767`.
|
||||
|
||||
The base image intentionally does not bundle agent CLIs. Extend it with the
|
||||
agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security,
|
||||
agent auth, and troubleshooting notes.
|
||||
80
docker/base/Dockerfile
Normal file
80
docker/base/Dockerfile
Normal file
@@ -0,0 +1,80 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
|
||||
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/*
|
||||
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
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 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"]
|
||||
101
docker/base/Dockerfile.source
Normal file
101
docker/base/Dockerfile.source
Normal file
@@ -0,0 +1,101 @@
|
||||
# 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"]
|
||||
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_HOME="/home/paseo"
|
||||
|
||||
: "${HOME:=$IMAGE_HOME}"
|
||||
: "${PASEO_HOME:=${HOME}/.paseo}"
|
||||
: "${PASEO_LISTEN:=0.0.0.0:6767}"
|
||||
: "${PASEO_WEB_UI_ENABLED:=true}"
|
||||
: "${PASEO_LOG_LEVEL:=info}"
|
||||
: "${PASEO_LOG_FORMAT:=json}"
|
||||
: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}"
|
||||
: "${CODEX_HOME:=${HOME}/.codex}"
|
||||
: "${XDG_CONFIG_HOME:=${HOME}/.config}"
|
||||
: "${XDG_DATA_HOME:=${HOME}/.local/share}"
|
||||
: "${XDG_STATE_HOME:=${HOME}/.local/state}"
|
||||
: "${XDG_CACHE_HOME:=${HOME}/.cache}"
|
||||
|
||||
export HOME
|
||||
export PASEO_HOME
|
||||
export PASEO_LISTEN
|
||||
export PASEO_WEB_UI_ENABLED
|
||||
export PASEO_LOG_LEVEL
|
||||
export PASEO_LOG_FORMAT
|
||||
export CLAUDE_CONFIG_DIR
|
||||
export CODEX_HOME
|
||||
export XDG_CONFIG_HOME
|
||||
export XDG_DATA_HOME
|
||||
export XDG_STATE_HOME
|
||||
export XDG_CACHE_HOME
|
||||
|
||||
ensure_dir() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
local owner
|
||||
owner="$(stat -c "%u" "$dir")"
|
||||
if [[ "$owner" == "0" ]]; then
|
||||
chown paseo:paseo "$dir"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dir "$HOME"
|
||||
ensure_dir "$PASEO_HOME"
|
||||
ensure_dir "$CLAUDE_CONFIG_DIR"
|
||||
ensure_dir "$CODEX_HOME"
|
||||
ensure_dir "$XDG_CONFIG_HOME"
|
||||
ensure_dir "$XDG_DATA_HOME"
|
||||
ensure_dir "$XDG_STATE_HOME"
|
||||
ensure_dir "$XDG_CACHE_HOME"
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo "$@"
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [[ -z "${PASEO_PASSWORD:-}" ]]; then
|
||||
{
|
||||
echo "[paseo] WARNING: PASEO_PASSWORD is not set."
|
||||
echo "[paseo] The daemon accepts unauthenticated control connections from any client that can reach it."
|
||||
echo "[paseo] Set PASEO_PASSWORD for any published port or network-reachable deployment."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
if [[ ! -f /etc/paseo-server-entry ]]; then
|
||||
echo "[paseo] FATAL: /etc/paseo-server-entry is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
entry="$(cat /etc/paseo-server-entry)"
|
||||
echo "[paseo] starting daemon on ${PASEO_LISTEN} with web UI ${PASEO_WEB_UI_ENABLED}"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo node "$entry"
|
||||
fi
|
||||
exec node "$entry"
|
||||
21
docker/docker-compose.example.yml
Normal file
21
docker/docker-compose.example.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Minimal Paseo daemon + web UI deployment.
|
||||
#
|
||||
# Open http://localhost:6767 after `docker compose up -d`.
|
||||
# For any network-reachable deployment, change PASEO_PASSWORD first.
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
container_name: paseo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
# Add DNS names you use to reach this container. IPs and localhost are
|
||||
# already allowed by default.
|
||||
# PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
volumes:
|
||||
# Persistent daemon state and agent credentials/config.
|
||||
- ./paseo-home:/home/paseo
|
||||
# Code visible to Paseo and the agents it launches.
|
||||
- ./workspace:/workspace
|
||||
@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus 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.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -169,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -190,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
|
||||
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
@@ -202,13 +210,17 @@ Set these to select OpenAI instead of local speech:
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -217,7 +229,7 @@ OpenAI voice can be configured under `providers.openai`:
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
|
||||
@@ -49,37 +49,11 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Expo Router layout ownership
|
||||
### Expo Router
|
||||
|
||||
Each layout owns only the routes directly inside its directory. In the root
|
||||
layout, register `h/[serverId]`; do not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
|
||||
routes with its own nested stack and relative screen names:
|
||||
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
|
||||
warns with `[Layout children]: No route named ...` when a layout registers
|
||||
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
|
||||
leave a nested index route mounted without its local dynamic params and render a
|
||||
blank screen.
|
||||
|
||||
Do not paper over missing required route params by reading global params in the
|
||||
leaf. Required dynamic params belong to the matched route. If
|
||||
`useLocalSearchParams()` misses one, fix the layout ownership.
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree. Put shared route policy in
|
||||
`src/navigation`, `src/utils`, or another non-route directory.
|
||||
|
||||
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
|
||||
workspace for that host after the workspace-selection store hydrates unless the
|
||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||
remembered workspace go to `open-project`.
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId`/
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
Route ownership, startup restore, and native blank-screen gotchas live in
|
||||
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
|
||||
startup routing, remembered workspace restore, or active workspace selection.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
@@ -98,6 +72,10 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
||||
|
||||
### React render profiling
|
||||
|
||||
The app has a gated React render profiler in
|
||||
|
||||
239
docs/docker.md
Normal file
239
docs/docker.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Running Paseo in Docker
|
||||
|
||||
Paseo publishes a container image for running the daemon on a server, VM, NAS,
|
||||
or homelab box. The image also serves the bundled browser web UI, so one
|
||||
container gives you both the daemon API and a self-hosted UI.
|
||||
|
||||
The image source lives in [`docker/`](../docker/).
|
||||
|
||||
## How it works
|
||||
|
||||
The official image:
|
||||
|
||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||
or from source-built workspace tarballs for beta images
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
- stores daemon state and agent credentials under `/home/paseo`
|
||||
- leaves agent CLIs out of the base image
|
||||
|
||||
Open the container's HTTP origin, for example `http://localhost:6767`, to load
|
||||
the web UI. The served app receives a same-origin connection hint and connects
|
||||
back to that daemon. Static UI files load without daemon auth; API and
|
||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:6767
|
||||
```
|
||||
|
||||
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
|
||||
daemon connection in the web UI or another Paseo client.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
|
||||
|
||||
```bash
|
||||
cp docker/docker-compose.example.yml docker-compose.yml
|
||||
$EDITOR docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
volumes:
|
||||
- ./paseo-home:/home/paseo
|
||||
- ./workspace:/workspace
|
||||
```
|
||||
|
||||
## Installing Agents
|
||||
|
||||
The base image does not preinstall Claude Code, Codex, OpenCode, Copilot, Pi, or
|
||||
other agent CLIs. That keeps the default image small and avoids coupling Paseo
|
||||
releases to third-party agent release cycles.
|
||||
|
||||
Create a child image for the agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
|
||||
```
|
||||
|
||||
Build it:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile -t paseo-with-agents .
|
||||
```
|
||||
|
||||
Then use `image: paseo-with-agents` in Compose.
|
||||
|
||||
Leave the child image user as root. The base entrypoint uses root only for
|
||||
first-run directory setup, then drops the daemon and launched agents to the
|
||||
non-root `paseo` user.
|
||||
|
||||
An example child image is in
|
||||
[`docker/Dockerfile.agents.example`](../docker/Dockerfile.agents.example).
|
||||
|
||||
You can also mount credentials from the host or run agent login once inside the
|
||||
container:
|
||||
|
||||
```bash
|
||||
docker exec -it --user paseo paseo codex
|
||||
docker exec -it --user paseo paseo claude
|
||||
```
|
||||
|
||||
Agent credentials and config persist in `/home/paseo`, alongside daemon state.
|
||||
Provider environment variables such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
|
||||
`OPENAI_BASE_URL`, or `ANTHROPIC_BASE_URL` can be passed through `docker run -e`
|
||||
or `compose.environment`; Paseo passes them to launched agents.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Purpose |
|
||||
| ------------- | ------------------------------------------------------------------------ |
|
||||
| `/home/paseo` | Paseo state under `.paseo` plus agent config such as `.codex`, `.claude` |
|
||||
| `/workspace` | Code that Paseo and launched agents can read and write |
|
||||
|
||||
The image defaults:
|
||||
|
||||
| Variable | Default |
|
||||
| -------------- | -------------------- |
|
||||
| `HOME` | `/home/paseo` |
|
||||
| `PASEO_HOME` | `/home/paseo/.paseo` |
|
||||
| `PASEO_LISTEN` | `0.0.0.0:6767` |
|
||||
|
||||
If you bind-mount host directories on Linux, make sure the container user can
|
||||
write them. The built-in `paseo` user has uid/gid `1000:1000`. For a different
|
||||
host uid/gid, either adjust ownership on the mounted directories or run the
|
||||
container with Docker's `--user` / Compose `user:` option.
|
||||
|
||||
## Reverse Proxies
|
||||
|
||||
When serving Paseo behind a reverse proxy, forward normal HTTP requests and
|
||||
WebSocket upgrades to the same daemon port.
|
||||
|
||||
Caddy example:
|
||||
|
||||
```caddy
|
||||
paseo.example.com {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
```
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name paseo.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6767;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you reach the daemon by DNS name, set `PASEO_HOSTNAMES` so host-header
|
||||
validation allows that name:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
```
|
||||
|
||||
IPs and `localhost` are allowed by default.
|
||||
|
||||
## Security
|
||||
|
||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||
want to expose the daemon port directly.
|
||||
- The container is the isolation boundary for agents. Agents can read and write
|
||||
whatever you mount into `/workspace` and whatever credentials you place in
|
||||
`/home/paseo`.
|
||||
- The bundled web UI static files are public on the daemon origin. The daemon
|
||||
API and WebSocket remain protected by password auth when configured.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -t paseo:local docker/base
|
||||
```
|
||||
|
||||
To bake a specific published npm version:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
docker/base
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
non-publishing check. Stable `vX.Y.Z` tag pushes publish
|
||||
`ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`. Beta tags
|
||||
publish only the exact prerelease tag, such as
|
||||
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
|
||||
|
||||
To replace a Docker image in place without rebuilding desktop, APK, or EAS
|
||||
mobile release artifacts, dispatch the Docker workflow manually instead of
|
||||
pushing a `v*` release tag:
|
||||
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The web UI loads but cannot connect**: if `PASEO_PASSWORD` is set, add a
|
||||
direct connection with the same password.
|
||||
- **403 Host not allowed**: set `PASEO_HOSTNAMES` to the DNS names you use.
|
||||
- **Provider not available**: install that agent CLI in a child image or mount a
|
||||
runtime where the binary is on `PATH`.
|
||||
- **Permission errors in `/workspace`**: make the mounted directory writable by
|
||||
uid/gid `1000:1000`, or run the container as the host uid/gid.
|
||||
- **Logs**: inspect `docker logs paseo` or
|
||||
`/home/paseo/.paseo/daemon.log` inside the container.
|
||||
128
docs/expo-router.md
Normal file
128
docs/expo-router.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Expo Router
|
||||
|
||||
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
|
||||
not fail loudly when a nested native route is mounted under the wrong layout. The
|
||||
usual symptom is a white or blank native screen with no JavaScript crash.
|
||||
|
||||
Read this before changing `packages/app/src/app`, startup routing, remembered
|
||||
workspace restore, or active workspace selection.
|
||||
|
||||
## Ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory.
|
||||
|
||||
- The root layout registers `h/[serverId]`.
|
||||
- The root layout does not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index`.
|
||||
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
||||
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
||||
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
|
||||
|
||||
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
||||
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
||||
shape can leave a nested index route mounted without its local dynamic params and
|
||||
render a blank screen.
|
||||
|
||||
## Startup
|
||||
|
||||
The root `/` route chooses a host boundary. It does not jump directly into a host
|
||||
leaf.
|
||||
|
||||
- Good: `/` -> `/h/[serverId]`
|
||||
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
|
||||
|
||||
`/h/[serverId]` is the host home route. The host index restores the last
|
||||
remembered workspace for that host after the remembered selection has hydrated
|
||||
and the workspace has not been proven missing. If there is no restorable
|
||||
workspace, it goes to global `/open-project`.
|
||||
|
||||
This restore is based on the last navigated workspace, not current connection
|
||||
status. Do not redirect to another online host just because the remembered host
|
||||
is still connecting or offline; the workspace screen owns that offline/loading
|
||||
state.
|
||||
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
||||
Do not make the caller branch on its current route.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
Hidden host routes may keep their local params while an app-wide route is
|
||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||
only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
Do not paper over missing required params by reading global params in the leaf.
|
||||
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
|
||||
startup route shape.
|
||||
|
||||
Use the host route context for host-owned leaves that need the host id after
|
||||
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
|
||||
unmatched tree by guessing from global state.
|
||||
|
||||
## App Directory
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree.
|
||||
|
||||
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
|
||||
non-route directory.
|
||||
|
||||
## Native Stack
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId` and
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
route-tree state, so a real regression should launch native with seeded persisted
|
||||
state:
|
||||
|
||||
1. Seed `paseo:last-workspace-route-selection` with a valid
|
||||
`{ serverId, workspaceId }`.
|
||||
2. Launch the native app cold.
|
||||
3. Assert a real screen is visible, not the blank tree.
|
||||
4. Assert no `[Layout children]` warning appears.
|
||||
|
||||
The pure policy tests should still enforce the boundary split:
|
||||
|
||||
- root startup with a saved workspace returns `/h/[serverId]`;
|
||||
- host index with the same saved workspace returns
|
||||
`/h/[serverId]/workspace/[workspaceId]`;
|
||||
- host index with no restorable workspace returns `/open-project`.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
before stores, themes, or rendering.
|
||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `which opencode`: `opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
|
||||
@@ -56,15 +56,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ Before running any stable patch release command:
|
||||
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`, 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.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -271,9 +273,31 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
For Docker-only retries, **do not push or force-push a `v*` release tag**.
|
||||
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
|
||||
and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
desktop, APK, or EAS release builders. The Docker exception is safe because the
|
||||
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
|
||||
not check out or move the `v*` release tag.
|
||||
|
||||
To retry a failed non-Docker release workflow, push a retry tag on the commit
|
||||
you want to build. Reusing the same tag name is expected: move it with
|
||||
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||
you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
|
||||
|
||||
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
|
||||
1671
package-lock.json
generated
1671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
@@ -6,8 +6,20 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
waitForScrollableChat,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
@@ -25,6 +37,89 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-away-",
|
||||
title: "Scroll-away anchor",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
|
||||
|
||||
const finalMetrics = await readScrollMetrics(page);
|
||||
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await withWorkspace({
|
||||
prefix: "stream-scroll-away-delayed-history-",
|
||||
});
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await selectModel(page, "Five minute stream");
|
||||
|
||||
const prompt = "Stream for delayed authoritative history scroll-away test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
@@ -36,6 +131,7 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await scrollAgentChatToBottom(page);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
49
packages/app/e2e/command-center-host.spec.ts
Normal file
49
packages/app/e2e/command-center-host.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
@@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from(
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
const TEST_JSON = {
|
||||
name: "config.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
@@ -172,6 +178,47 @@ test.describe("Composer attachments", () => {
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in active chat", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -142,6 +148,50 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const summary = await page
|
||||
.locator('[data-testid^="workspace-deck-entry-"]')
|
||||
.evaluateAll((elements, target) => {
|
||||
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
||||
const entries = elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
testId: element.getAttribute("data-testid"),
|
||||
hasLayout: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
});
|
||||
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
||||
return {
|
||||
totalDeckEntryCount: entries.length,
|
||||
currentWorkspaceEntryCount: currentEntries.length,
|
||||
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
||||
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
||||
};
|
||||
}, input);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalDeckEntryCount: input.expectedDeckEntryCount,
|
||||
currentWorkspaceEntryCount: 1,
|
||||
visibleCurrentWorkspaceEntryCount: 1,
|
||||
hiddenCurrentWorkspaceEntryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
@@ -308,6 +358,82 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
|
||||
try {
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-a-",
|
||||
title: "Autocomplete new route A",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-b-",
|
||||
title: "Autocomplete new route B",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-c-",
|
||||
title: "Autocomplete new route C",
|
||||
}),
|
||||
);
|
||||
|
||||
const [first, second, third] = sessions;
|
||||
|
||||
await openAgentRoute(page, first);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSettingsThenBackToWorkspace(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: third.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: first.workspaceId,
|
||||
});
|
||||
|
||||
await composerLocator(page).fill("/");
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ hasText: "/help", visible: true })
|
||||
.first();
|
||||
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -73,6 +73,23 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill("paseo-loading-state-no-match");
|
||||
|
||||
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||
// project row in place with a ghost "+ New workspace" child row.
|
||||
test.describe("Project with no workspaces persists", () => {
|
||||
|
||||
@@ -10,6 +10,9 @@ import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
host?: string;
|
||||
@@ -403,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
);
|
||||
}
|
||||
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
@@ -573,8 +567,18 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
relayProcess = spawn(
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
process.execPath,
|
||||
[
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
@@ -663,7 +667,7 @@ interface DaemonSpawnArgs {
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
@@ -672,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
// Default app E2E does not cover speech flows. Keep these disabled so
|
||||
// unrelated tests never start background local-model downloads.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
@@ -54,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -66,3 +86,65 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function waitForScrollableChat(
|
||||
page: Page,
|
||||
input: { minScrollableDistance: number; timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight - metrics.viewportHeight;
|
||||
},
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
.toBeGreaterThan(input.minScrollableDistance);
|
||||
}
|
||||
|
||||
export async function scrollChatAwayFromBottom(
|
||||
page: Page,
|
||||
input: { deltaY: number; minDistanceFromBottom: number },
|
||||
): Promise<ScrollMetrics> {
|
||||
const scroll = getVisibleChatScroll(page);
|
||||
const box = await scroll.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(input.minDistanceFromBottom);
|
||||
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
|
||||
): Promise<void> {
|
||||
const durationMs = input?.durationMs ?? 2_000;
|
||||
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
|
||||
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
|
||||
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
await page.waitForTimeout(sampleIntervalMs);
|
||||
const metrics = await readScrollMetrics(page);
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
offsetY: metrics.offsetY,
|
||||
contentHeight: metrics.contentHeight,
|
||||
});
|
||||
expect(
|
||||
metrics.offsetY,
|
||||
JSON.stringify({ baseline, samples: samples.slice(-12) }),
|
||||
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
|
||||
}
|
||||
}
|
||||
|
||||
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreatedAgentTimelineGate {
|
||||
release(): void;
|
||||
waitForCreatedAgent(): Promise<string>;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return message.payload && typeof message.payload === "object"
|
||||
? (message.payload as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
let createdAgentId: string | null = null;
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
let resolveForwardedResponse: (() => void) | null = null;
|
||||
const createdAgentSeen = new Promise<string>((resolve) => {
|
||||
resolveCreatedAgent = resolve;
|
||||
});
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
const forwardedResponse = new Promise<void>((resolve) => {
|
||||
resolveForwardedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
const forwardToClient = (message: WebSocketMessage) => {
|
||||
ws.send(message);
|
||||
resolveForwardedResponse?.();
|
||||
};
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
|
||||
const agentId = payload.agentId;
|
||||
if (typeof agentId === "string") {
|
||||
createdAgentId = agentId;
|
||||
resolveCreatedAgent?.(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMessage?.type === "fetch_agent_timeline_response") {
|
||||
const agentId = payload?.agentId;
|
||||
const direction = payload?.direction;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
typeof agentId === "string" &&
|
||||
agentId === createdAgentId &&
|
||||
direction === "tail"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
forwardToClient(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => forwardToClient(message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreatedAgent: () => createdAgentSeen,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
@@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const modelButton = page
|
||||
.getByRole("button", { name: /Select model/i })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await modelButton.isVisible().catch(() => false)) {
|
||||
await modelButton.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
|
||||
@@ -88,11 +88,13 @@ export async function archiveAgentFromDaemon(
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
|
||||
9
packages/app/e2e/helpers/command-center.ts
Normal file
9
packages/app/e2e/helpers/command-center.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -95,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function dropFileOnComposer(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const dataTransfer = await page.evaluateHandle(
|
||||
({ name, mimeType, base64 }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
const droppedFile = new File([bytes], name, { type: mimeType });
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(droppedFile);
|
||||
return transfer;
|
||||
},
|
||||
{
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.buffer.toString("base64"),
|
||||
},
|
||||
);
|
||||
|
||||
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
|
||||
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("dragover", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("drop", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
|
||||
@@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
79
packages/app/e2e/helpers/hosts.ts
Normal file
79
packages/app/e2e/helpers/hosts.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function openNewWorkspaceComposer(
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export async function openNewWorkspaceComposer(
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
||||
// rpc_error shape the daemon emits for failed async session requests.
|
||||
export async function installReadTransportFailure(
|
||||
page: Page,
|
||||
): Promise<{ allowRecovery: () => void }> {
|
||||
let shouldFailReads = true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "read_project_config_request",
|
||||
error: "Test read transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
allowRecovery() {
|
||||
shouldFailReads = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
|
||||
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
||||
if (input.total > 1) {
|
||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
||||
// either role by accessible name.
|
||||
function questionOption(page: Page, option: string) {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
|
||||
}
|
||||
|
||||
// The multi-question nav renders as a tablist; each question is a tab.
|
||||
function questionNavTab(page: Page, input: { index: number; total: number }) {
|
||||
return page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await questionOption(page, option).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
await questionNavTab(page, input).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionNavigationEnabled(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toBeEnabled();
|
||||
await expect(questionNavTab(page, input)).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
|
||||
249
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
249
packages/app/e2e/helpers/schedule-fake-host.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
import { type SeededWorkspace } from "./seed-client";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildFakeProviderEntries(nowIso: string) {
|
||||
return [
|
||||
{
|
||||
provider: "mock",
|
||||
label: "Mock",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
fetchedAt: nowIso,
|
||||
models: [
|
||||
{
|
||||
provider: "mock",
|
||||
id: FAKE_HOST_MODEL_ID,
|
||||
label: FAKE_HOST_MODEL_LABEL,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [{ id: "load-test", label: "Load test" }],
|
||||
defaultModeId: "load-test",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
|
||||
const parsed = parseJson(message);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope = parsed as { type?: string; message?: SessionRequest };
|
||||
if (envelope.type !== "session" || !envelope.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.message;
|
||||
}
|
||||
|
||||
function getRequestId(request: SessionRequest): string {
|
||||
return typeof request.requestId === "string" ? request.requestId : "fake-request";
|
||||
}
|
||||
|
||||
export async function buildFakeScheduleHostWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<FakeScheduleHostWorkspace> {
|
||||
const workspaceList = await workspace.client.fetchWorkspaces({
|
||||
filter: { projectId: workspace.projectId },
|
||||
});
|
||||
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
|
||||
if (!baseWorkspace) {
|
||||
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
|
||||
}
|
||||
|
||||
const projectId = `${workspace.projectId}-fake-host`;
|
||||
const cwd = `${workspace.repoPath}-fake-host`;
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
id: `${baseWorkspace.id}-fake-host`,
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
projectRootPath: cwd,
|
||||
workspaceDirectory: cwd,
|
||||
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
project: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installFakeScheduleHost(input: {
|
||||
page: Page;
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
const parsed = parseJson(message);
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
|
||||
ws.send(
|
||||
buildSessionMessage("status", {
|
||||
status: "server_info",
|
||||
serverId: input.serverId,
|
||||
hostname: "fake-schedule-host",
|
||||
version: "0.0.0-e2e",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = readSessionRequest(message);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = getRequestId(request);
|
||||
const now = Date.now();
|
||||
const nowIso = new Date(now).toISOString();
|
||||
switch (request.type) {
|
||||
case "ping":
|
||||
ws.send(
|
||||
buildSessionMessage("pong", {
|
||||
requestId,
|
||||
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
|
||||
serverReceivedAt: now,
|
||||
serverSentAt: now,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_workspaces_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_workspaces_response", {
|
||||
requestId,
|
||||
entries: [input.workspace],
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
requestId,
|
||||
entries: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "get_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("get_providers_snapshot_response", {
|
||||
requestId,
|
||||
entries: buildFakeProviderEntries(nowIso),
|
||||
generatedAt: nowIso,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "refresh_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("refresh_providers_snapshot_response", {
|
||||
requestId,
|
||||
acknowledged: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "schedule/list":
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function addFakeScheduleHostAndReload(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: string;
|
||||
}): Promise<void> {
|
||||
const host = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await input.page.evaluate(
|
||||
({ seededHost, keys }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await input.page.reload();
|
||||
}
|
||||
@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
|
||||
@@ -393,9 +393,9 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
|
||||
// open) that surfaces the local host by its label. The "Local" marker only
|
||||
// appears on dropdown rows in the multi-host case, which this fixture does not
|
||||
// exercise.
|
||||
// open) that surfaces the local host by its label. The per-row connection
|
||||
// endpoint only appears on dropdown rows in the multi-host case, which this
|
||||
// fixture does not exercise.
|
||||
const picker = sidebar.getByTestId("settings-host-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
|
||||
32
packages/app/e2e/helpers/speech-env.ts
Normal file
32
packages/app/e2e/helpers/speech-env.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
const DISABLED_E2E_SPEECH_ENV = {
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
} as const;
|
||||
|
||||
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
// Default app E2E does not cover speech flows; keep restarts from starting
|
||||
// background local-model downloads for unrelated tests.
|
||||
const next: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
...DISABLED_E2E_SPEECH_ENV,
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -6,16 +6,19 @@ import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
@@ -38,6 +41,19 @@ test.describe("New workspace entry points", () => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-new-workspace-host",
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
@@ -48,16 +64,96 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(globalButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(page.getByTestId("host-chooser")).toHaveCount(0);
|
||||
|
||||
// The screen is up: its project picker trigger is the canonical landmark.
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the New Workspace screen hides the host selector when there is only one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
const otherProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "aa-new-workspace-archive-other-",
|
||||
});
|
||||
const rememberedProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "zz-new-workspace-archive-remembered-",
|
||||
});
|
||||
const serverId = getServerId();
|
||||
const draftText = "keep this new workspace draft";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId,
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page
|
||||
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await composer.fill(draftText);
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
|
||||
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
|
||||
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
} finally {
|
||||
await otherProject.cleanup();
|
||||
await rememberedProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -119,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
|
||||
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const OFFLINE_SERVER_IDS = [
|
||||
"srv_e2e_preselect_offline_1",
|
||||
"srv_e2e_preselect_offline_2",
|
||||
"srv_e2e_preselect_offline_3",
|
||||
];
|
||||
|
||||
// New Workspace preselection is a form-context decision, not startup routing.
|
||||
// Entry points from a workspace should carry the current project context, and a
|
||||
// plain /new must not let a stale remembered offline host steal the initial host
|
||||
// when there is exactly one online saved host.
|
||||
|
||||
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+n`);
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
projectDisplayName: string,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
|
||||
}
|
||||
|
||||
async function expectAnyProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const label = ((await projectPicker.textContent()) ?? "").trim();
|
||||
return label || "Choose project";
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.not.toBe("Choose project");
|
||||
}
|
||||
|
||||
async function openColdRestoredWorkspaceWithOfflineHostFirst(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId, workspaceId }) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: connectedServerId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function openNewWorkspaceWithStaleOfflineSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(buildNewWorkspaceRoute());
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function seedOfflineHostsWithStaleSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("New workspace preselects the open workspace's project", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
let projectA: SeededWorkspace;
|
||||
let projectB: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
|
||||
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await projectA?.cleanup();
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNewWorkspaceWithStaleOfflineSelection(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectAnyProjectPreselectedWithin(page, 8_000);
|
||||
});
|
||||
|
||||
test("stale remembered offline host heals after visiting the connected workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const connectedServerId = getServerId();
|
||||
await seedOfflineHostsWithStaleSelection(page);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -131,7 +133,64 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
return readFile(path.join(project.path, "paseo.json"), "utf8");
|
||||
}
|
||||
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: path.basename(projectPath) })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`);
|
||||
await expect(openSettingsItem).toBeVisible({ timeout: 10_000 });
|
||||
await openSettingsItem.click();
|
||||
}
|
||||
|
||||
test.describe("Projects settings", () => {
|
||||
test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||
const client = await connectSeedClient();
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
projectId = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectId);
|
||||
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
@@ -223,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
// Reject read_project_config_request calls until the user clicks Reload.
|
||||
// This keeps automatic reconnect refetches from racing past the callout.
|
||||
const transportFailure = await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
@@ -233,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
||||
transportFailure.allowRecovery();
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
|
||||
92
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
92
packages/app/e2e/schedules-edit-model-hydration.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
|
||||
return result.schedule.id;
|
||||
}
|
||||
|
||||
function ignoreScheduleDeleteError(): void {}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
await (workspace.client as unknown as ScheduleSeedClient)
|
||||
.scheduleDelete({ id })
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
153
packages/app/e2e/schedules-project-target.spec.ts
Normal file
153
packages/app/e2e/schedules-project-target.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
target: { type: string; config?: { cwd?: string } };
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: /select model/i }).click();
|
||||
const popup = page.getByTestId("combobox-desktop-container");
|
||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||
await popup.getByText(label, { exact: true }).click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (schedule) {
|
||||
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScheduleCreatedForProject(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||
expect(schedule).toEqual(
|
||||
expect.objectContaining({
|
||||
name: input.name,
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Schedules project target", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByRole("button", { name: "Schedules" }).click();
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
await expect(page).not.toHaveURL(/\/h\//);
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
||||
await page.getByRole("button", { name: "Cron" }).click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
||||
});
|
||||
|
||||
test("clears the selected model when the chosen project moves to another host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
fakeHost.projectDisplayName,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
test("sidebar pins the local daemon host first", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
|
||||
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
||||
test.describe("Model B sidebar shape", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||
// none, since worktree creation needs a git checkout.
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The global new-workspace button is the universal entry — present for both
|
||||
// kinds regardless of their per-row affordance.
|
||||
|
||||
@@ -132,6 +132,25 @@ test.describe("Sidebar workspace list", () => {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card shows host as metadata", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await row.hover();
|
||||
|
||||
const hoverCard = page.getByTestId("workspace-hover-card");
|
||||
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: input.paseoHome,
|
||||
PASEO_SERVER_ID: SERVER_ID,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: input.origin,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -256,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
|
||||
|
||||
await expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -133,7 +133,7 @@
|
||||
"serve-sim": "^0.1.40",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.75.0",
|
||||
"wrangler": "^4.105.0",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
|
||||
return owners;
|
||||
}
|
||||
|
||||
function footerAssistantIds(layout: StreamLayout): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...layout.liveHead.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...layout.history, ...layout.liveHead].flatMap((item) =>
|
||||
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
@@ -289,4 +309,144 @@ describe("layoutStream", () => {
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([tool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places split live-head tool footer using the assistant from history on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
head: [tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[tool.id]: assistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for footer content while placing after the visible turn end on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
const latestAssistant = assistantMessage("a2", 4);
|
||||
const latestTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
firstTool,
|
||||
latestAssistant,
|
||||
latestTool,
|
||||
userMessage("u2", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, latestAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestTool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const secondAssistant = assistantMessage("a2", 4);
|
||||
const secondTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
userMessage("u2", 3),
|
||||
secondAssistant,
|
||||
secondTool,
|
||||
userMessage("u3", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, secondAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
|
||||
firstAssistant.id,
|
||||
);
|
||||
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
|
||||
secondAssistant.id,
|
||||
);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[firstAssistant.id]: firstAssistant.id,
|
||||
[secondTool.id]: secondAssistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"does not render a completed footer before tool rows while the turn is running on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
@@ -68,45 +75,111 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
;
|
||||
index >= 0 && index < items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!canCrossBoundary ||
|
||||
!input.boundaryAboveItems ||
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
index = input.boundaryAboveIndex;
|
||||
canCrossBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (latestIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
function resolveCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running"))
|
||||
);
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
||||
function isToolSequenceItem(
|
||||
item: StreamItem | null,
|
||||
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
@@ -174,19 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -227,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
@@ -245,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
} else {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -253,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -262,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -270,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -194,7 +194,9 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
expect(scrollContainer).toBeInstanceOf(HTMLElement);
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
@@ -205,4 +207,408 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [],
|
||||
liveHead: [],
|
||||
},
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
const scrollElement = scrollContainer;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 1400 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollElement.scrollTop).toBe(1400);
|
||||
});
|
||||
|
||||
it("does not force bottom on delayed route readiness after the user scrolls away", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3)],
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
contentRef.current = node;
|
||||
}, []);
|
||||
const [followOutput, setFollowOutputr] = useState(true);
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
followOutputRef.current = value;
|
||||
setFollowOutputr(value);
|
||||
return value;
|
||||
};
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const isPointerScrollActiveRef = useRef(false);
|
||||
@@ -145,8 +146,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
followOutputRef.current = followOutput;
|
||||
|
||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
|
||||
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
@@ -276,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom) {
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (scrolledUp) {
|
||||
if (scrolledUp || !isAtBottom) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
@@ -318,6 +322,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!isActivationReady) {
|
||||
return;
|
||||
}
|
||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
setFollowOutput(true);
|
||||
forceStickToBottom();
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -336,7 +343,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
|
||||
}, [
|
||||
activationKey,
|
||||
forceStickToBottom,
|
||||
hasRouteBottomAnchorRequest,
|
||||
isActivationReady,
|
||||
scheduleStickToBottom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
|
||||
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
messageId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAssistantTurnBoundaryMessageId", () => {
|
||||
it("uses the selected assistant message id", () => {
|
||||
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), selected],
|
||||
startIndex: 1,
|
||||
}),
|
||||
).toBe("msg-assistant-1");
|
||||
});
|
||||
|
||||
it("does not borrow a boundary id from another assistant in the same turn", () => {
|
||||
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
const selected = assistantMessage("assistant-2", 3);
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), first, selected],
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the selected item to be an assistant message", () => {
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
|
||||
startIndex: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
}): string | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import {
|
||||
AssistantTurnFooter,
|
||||
LiveElapsed,
|
||||
STREAM_METADATA_FONT_SIZE,
|
||||
type AssistantForkTarget,
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||
items,
|
||||
startIndex,
|
||||
});
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
TurnFooter,
|
||||
type AssistantTurnForkHandler,
|
||||
type TurnContentStrategy,
|
||||
} from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
@@ -76,11 +82,21 @@ import {
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
missingAttachmentMessage: string;
|
||||
}): WorkspaceComposerAttachment {
|
||||
if (!input.payload.attachment) {
|
||||
throw new Error(input.missingAttachmentMessage);
|
||||
}
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: `chat_history:${input.draftId}`,
|
||||
attachment: input.payload.attachment,
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||
if (!agent.provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftTabTarget(
|
||||
setup: WorkspaceDraftTabSetup | undefined,
|
||||
draftId: string,
|
||||
): WorkspaceTabTarget {
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
payload,
|
||||
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||
});
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||
attachments: [attachment],
|
||||
});
|
||||
return draftId;
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
const draftId = await prepareForkDraft();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
setup: draftSetup,
|
||||
sourceDirectory,
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
|
||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
function collectAgentProjectPlacementDiffs(
|
||||
left: AgentScreenAgent["projectPlacement"],
|
||||
right: AgentScreenAgent["projectPlacement"],
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
if (left?.projectName !== right?.projectName) {
|
||||
reasons.push("agent.projectPlacement.projectName");
|
||||
}
|
||||
if (left?.projectKey !== right?.projectKey) {
|
||||
reasons.push("agent.projectPlacement.projectKey");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.provider !== right.provider) reasons.push("agent.provider");
|
||||
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
|
||||
if (left.model !== right.model) reasons.push("agent.model");
|
||||
if (left.thinkingOptionId !== right.thinkingOptionId) {
|
||||
reasons.push("agent.thinkingOptionId");
|
||||
}
|
||||
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
|
||||
reasons.push("agent.runtimeInfo.modeId");
|
||||
}
|
||||
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
|
||||
reasons.push("agent.runtimeInfo.model");
|
||||
}
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||
if (left.id !== right.id) reasons.push("agent.id");
|
||||
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
|
||||
if (left.status !== right.status) reasons.push("agent.status");
|
||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||
reasons.push("agent.capabilities");
|
||||
}
|
||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
reasons.push(...collectAgentSetupDiffs(left, right));
|
||||
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
|
||||
return reasons;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -58,6 +64,7 @@ import {
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -80,6 +87,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
@@ -126,14 +134,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,6 +313,9 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
if (hasConfiguredLocalDaemonOverride()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
@@ -878,7 +888,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const routeHasKnownHost =
|
||||
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
|
||||
const shouldShowAppChrome =
|
||||
storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost);
|
||||
storeReady &&
|
||||
(pathname === "/open-project" ||
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -933,8 +948,10 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
@@ -944,12 +961,23 @@ function RootStack() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRouteNavigationBridge() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
return registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
|
||||
@@ -39,7 +39,6 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -84,7 +82,7 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent(agentId)
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
if (!serverId) return null;
|
||||
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
|
||||
return <Redirect href={buildOpenProjectRoute()} />;
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
if (!serverId || !isWorkspaceSelectionLoaded) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
href={resolveHostIndexRoute({
|
||||
serverId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,36 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
export default function NewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
draftId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
|
||||
const screenKey = JSON.stringify([
|
||||
serverId,
|
||||
sourceDirectory ?? null,
|
||||
displayName ?? null,
|
||||
projectId ?? null,
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
10
packages/app/src/app/schedules.tsx
Normal file
10
packages/app/src/app/schedules.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||
|
||||
export default function SchedulesRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SchedulesScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
149
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
149
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
MousePointer2,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
return count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count });
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
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(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
|
||||
export function getAgentAttachmentPillContent(
|
||||
attachment: AgentAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
switch (attachment.type) {
|
||||
case "review":
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceAttachmentPillContent(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
const intent = attachment.attachment.intent;
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title,
|
||||
subtitle: getPullRequestContextSubtitle(attachment),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.commentCount, t),
|
||||
};
|
||||
}
|
||||
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
|
||||
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentBrowserIcon = (
|
||||
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
describe("attachment file types", () => {
|
||||
it("keeps SVG as a file while treating raster image files as images", () => {
|
||||
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml");
|
||||
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("application/octet-stream");
|
||||
expect(isRasterImagePath("/tmp/logo.svg")).toBe(false);
|
||||
expect(isRasterImageMimeType("image/svg+xml")).toBe(false);
|
||||
expect(isRasterImageFile(new File(["<svg />"], "logo.svg", { type: "image/svg+xml" }))).toBe(
|
||||
@@ -18,11 +18,20 @@ describe("attachment file types", () => {
|
||||
);
|
||||
|
||||
expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
|
||||
expect(getMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
|
||||
expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true);
|
||||
expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true);
|
||||
expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require MIME table entries for generic file attachments", () => {
|
||||
expect(getMimeTypeFromPath("/tmp/notes.md")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/archive.zip")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/report.docx")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/runtime.log")).toBe("application/octet-stream");
|
||||
expect(getMimeTypeFromPath("/tmp/export.anything")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("does not offer SVG in the image picker extension list", () => {
|
||||
expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual(
|
||||
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
|
||||
|
||||
@@ -1,45 +1,3 @@
|
||||
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
".js": "text/javascript",
|
||||
".ts": "text/typescript",
|
||||
".tsx": "text/typescript-jsx",
|
||||
".jsx": "text/javascript",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".xml": "text/xml",
|
||||
".csv": "text/csv",
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
@@ -55,6 +13,7 @@ const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION));
|
||||
const GENERIC_FILE_MIME_TYPE = "application/octet-stream";
|
||||
|
||||
export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map(
|
||||
(extension) => extension.slice(1),
|
||||
@@ -75,7 +34,7 @@ export function getFileTypeLabel(path: string): string | null {
|
||||
}
|
||||
|
||||
export function getMimeTypeFromPath(path: string): string {
|
||||
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
|
||||
return getRasterImageMimeTypeFromPath(path) ?? GENERIC_FILE_MIME_TYPE;
|
||||
}
|
||||
|
||||
export function getRasterImageMimeTypeFromPath(path: string): string | null {
|
||||
|
||||
28
packages/app/src/attachments/picked-file.ts
Normal file
28
packages/app/src/attachments/picked-file.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getFileExtension } from "@/attachments/file-types";
|
||||
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
|
||||
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
|
||||
|
||||
export interface PickedFile {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
|
||||
const { path: managedPath } = await copyDesktopAttachmentFile({
|
||||
attachmentId: crypto.randomUUID(),
|
||||
sourcePath: path,
|
||||
extension: getFileExtension(path) || null,
|
||||
});
|
||||
const base64 = await readDesktopFileBase64(managedPath);
|
||||
return base64ToUint8Array(base64);
|
||||
}
|
||||
@@ -21,6 +21,12 @@ export interface AttachmentMetadata {
|
||||
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 {
|
||||
url: string;
|
||||
selector: string;
|
||||
@@ -42,6 +48,16 @@ export interface BrowserElementAttachment {
|
||||
} | null;
|
||||
parentChain: string[];
|
||||
children: string[];
|
||||
/** Free-text review note the user wrote about this element, if any. */
|
||||
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
|
||||
* alongside the textual element context. Persisted via the attachment store;
|
||||
* referenced by id so the draft-store GC keeps it alive.
|
||||
*/
|
||||
screenshot?: AttachmentMetadata;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +79,18 @@ export type PullRequestContextAttachment =
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
|
||||
export interface ChatHistoryContextAttachment {
|
||||
kind: "chat_history";
|
||||
id: string;
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||
source: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
@@ -75,6 +103,7 @@ export type WorkspaceComposerAttachment =
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| PullRequestContextAttachment
|
||||
| ChatHistoryContextAttachment
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
|
||||
import {
|
||||
createImageSourceCacheKey,
|
||||
fileUriToPath,
|
||||
localFileSourceToPath,
|
||||
parseDataUrl,
|
||||
parseImageDataUrl,
|
||||
pathToFileUri,
|
||||
} from "./utils";
|
||||
|
||||
describe("pathToFileUri", () => {
|
||||
it("converts POSIX absolute paths to file URIs", () => {
|
||||
@@ -23,6 +30,24 @@ describe("pathToFileUri", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileUriToPath", () => {
|
||||
it("converts Windows drive-letter file URIs back to paths", () => {
|
||||
expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localFileSourceToPath", () => {
|
||||
it("decodes markdown-encoded Windows drive-letter paths", () => {
|
||||
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
|
||||
});
|
||||
|
||||
it("preserves literal percent sequences in plain local paths", () => {
|
||||
expect(localFileSourceToPath("/tmp/image%20with%20literal%20percent.png")).toBe(
|
||||
"/tmp/image%20with%20literal%20percent.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataUrl", () => {
|
||||
it("accepts base64 data URLs with media-type parameters", () => {
|
||||
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
|
||||
|
||||
@@ -139,11 +139,41 @@ export function pathToFileUri(path: string): string {
|
||||
return `file:///${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
function decodeFilePathSource(source: string): string {
|
||||
try {
|
||||
return decodeURIComponent(source);
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsDrivePath(path: string): string {
|
||||
if (!/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
|
||||
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
|
||||
}
|
||||
|
||||
export function fileUriToPath(uri: string): string {
|
||||
if (!uri.startsWith("file://")) {
|
||||
return uri;
|
||||
}
|
||||
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
|
||||
const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, ""));
|
||||
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
|
||||
}
|
||||
|
||||
export function localFileSourceToPath(source: string): string {
|
||||
let path = source;
|
||||
if (source.startsWith("file://")) {
|
||||
path = fileUriToPath(source);
|
||||
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
|
||||
path = decodeFilePathSource(source);
|
||||
}
|
||||
return normalizeWindowsDrivePath(path);
|
||||
}
|
||||
|
||||
export function getFileExtensionFromName(fileName: string | null | undefined): string {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function isWorkspaceAttachment(
|
||||
return (
|
||||
attachment?.kind === "review" ||
|
||||
attachment?.kind === "browser_element" ||
|
||||
attachment?.kind === "chat_history" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export function userAttachmentsOnly(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" &&
|
||||
attachment.kind !== "browser_element" &&
|
||||
attachment.kind !== "chat_history" &&
|
||||
!isPullRequestContextAttachment(attachment),
|
||||
);
|
||||
}
|
||||
@@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
text: attachment.text,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return attachment.attachment;
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
appendWorkspaceAttachment,
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
collectWorkspaceAttachmentsForScopes,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "./workspace-attachments-store";
|
||||
@@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id,
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text,
|
||||
},
|
||||
source: {
|
||||
serverId: "local",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
@@ -76,6 +96,12 @@ describe("workspace attachments store", () => {
|
||||
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
||||
});
|
||||
|
||||
it("scopes draft attachments by draft id", () => {
|
||||
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
|
||||
"workspace-attachments:draft=draft-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes and clears attachments for a workspace scope", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -116,6 +142,13 @@ describe("workspace attachments store", () => {
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("dedupes repeated chat history attachments by id", () => {
|
||||
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
|
||||
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
|
||||
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("adds a workspace attachment against the current scope state", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -138,4 +171,25 @@ describe("workspace attachments store", () => {
|
||||
context,
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects attachments across requested scopes in scope order", () => {
|
||||
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const draftContext = chatHistoryAttachment("chat_history:draft-1");
|
||||
const workspaceContext = contextAttachment("comment-1");
|
||||
|
||||
expect(
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope: {
|
||||
[workspaceScopeKey]: [workspaceContext],
|
||||
[draftScopeKey]: [draftContext],
|
||||
},
|
||||
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
|
||||
}),
|
||||
).toEqual([draftContext, workspaceContext]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
|
||||
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
||||
|
||||
export interface WorkspaceAttachmentScopeInput {
|
||||
kind?: "workspace";
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface DraftWorkspaceAttachmentScopeInput {
|
||||
kind: "draft";
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export type WorkspaceAttachmentScope =
|
||||
| WorkspaceAttachmentScopeInput
|
||||
| DraftWorkspaceAttachmentScopeInput;
|
||||
|
||||
interface WorkspaceAttachmentsStoreState {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
}
|
||||
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
|
||||
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
|
||||
}
|
||||
|
||||
function areWorkspaceAttachmentsEqual(
|
||||
left: readonly WorkspaceComposerAttachment[],
|
||||
right: readonly WorkspaceComposerAttachment[],
|
||||
@@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual(
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
if (
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
const isContextAttachment =
|
||||
attachment.kind === "chat_history" ||
|
||||
attachment.kind === "github.pull_request_comment" ||
|
||||
attachment.kind === "github.pull_request_review" ||
|
||||
attachment.kind === "github.pull_request_check";
|
||||
if (!isContextAttachment) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
@@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
},
|
||||
}));
|
||||
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const { serverId, workspaceId, cwd } = input;
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
|
||||
const isDraftScope = input.kind === "draft";
|
||||
const draftId = isDraftScope ? input.draftId : "";
|
||||
const serverId = isDraftScope ? "" : input.serverId;
|
||||
const workspaceId = isDraftScope ? undefined : input.workspaceId;
|
||||
const cwd = isDraftScope ? "" : input.cwd;
|
||||
return useMemo(() => {
|
||||
if (isDraftScope) {
|
||||
return buildDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
}
|
||||
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
|
||||
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
|
||||
}
|
||||
|
||||
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
|
||||
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
|
||||
return useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||
[serverId, workspaceId, cwd],
|
||||
() => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
|
||||
[normalizedDraftId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
|
||||
);
|
||||
}
|
||||
|
||||
export function collectWorkspaceAttachmentsForScopes(input: {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
scopeKeys: readonly string[];
|
||||
}): readonly WorkspaceComposerAttachment[] {
|
||||
const attachments: WorkspaceComposerAttachment[] = [];
|
||||
for (const scopeKey of input.scopeKeys) {
|
||||
const normalizedScopeKey = scopeKey.trim();
|
||||
if (!normalizedScopeKey) {
|
||||
continue;
|
||||
}
|
||||
attachments.push(
|
||||
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export function useWorkspaceAttachmentsForScopes(
|
||||
scopeKeys: readonly string[] | undefined,
|
||||
): readonly WorkspaceComposerAttachment[] {
|
||||
const normalizedScopeKeys = useMemo(
|
||||
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
|
||||
[scopeKeys],
|
||||
);
|
||||
const attachmentsByScope = useWorkspaceAttachmentsStore(
|
||||
useShallow((state) => state.attachmentsByScope),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope,
|
||||
scopeKeys: normalizedScopeKeys,
|
||||
}),
|
||||
[attachmentsByScope, normalizedScopeKeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkspaceAttachmentsStore(): void {
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||
}
|
||||
|
||||
375
packages/app/src/browser-automation/handler.test.ts
Normal file
375
packages/app/src/browser-automation/handler.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} 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<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
|
||||
class FakeDaemonClient {
|
||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||
|
||||
public on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (request: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void {
|
||||
expect(type).toBe("browser.automation.execute.request");
|
||||
this.handler = handler;
|
||||
return () => {
|
||||
if (this.handler === handler) {
|
||||
this.handler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
|
||||
this.sentResponses.push(response);
|
||||
}
|
||||
|
||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||
this.handler?.(nextRequest);
|
||||
}
|
||||
}
|
||||
|
||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-1",
|
||||
command: { command: "list_tabs", args: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "/repo",
|
||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
||||
};
|
||||
}
|
||||
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
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() {
|
||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||
browserId: browser.browserId,
|
||||
workspaceId: "/repo",
|
||||
url: browser.url,
|
||||
title: browser.title,
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("mountBrowserAutomationHandler", () => {
|
||||
beforeEach(() => {
|
||||
useBrowserStore.setState({ browsersById: {} });
|
||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||
});
|
||||
|
||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
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({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
if (!workspaceKey) throw new Error("expected workspace key");
|
||||
const focusedTabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||
if (!focusedTabId) throw new Error("expected focused tab");
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () =>
|
||||
({
|
||||
browser: {
|
||||
executeAutomationCommand,
|
||||
registerWorkspaceBrowser,
|
||||
setWorkspaceActiveBrowser,
|
||||
setAgentActiveBrowser,
|
||||
},
|
||||
}) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
const payload = client.sentResponses[0]?.payload;
|
||||
expect(payload?.ok).toBe(true);
|
||||
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];
|
||||
expect(layout?.root.kind).toBe("pane");
|
||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
browserId: payload.result.browserId,
|
||||
});
|
||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ensureResidentBrowserWebview = vi.fn();
|
||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs: 1,
|
||||
registrationPollIntervalMs: 1,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await waitForAsyncWork();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => {
|
||||
throw new Error("IPC registration check failed");
|
||||
});
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sends a success response from the desktop bridge", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing bridge sends browser_unsupported", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed bridge errors become failure responses", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw {
|
||||
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 () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
const unsubscribe = mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
||||
expect(client.sentResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
343
packages/app/src/browser-automation/handler.ts
Normal file
343
packages/app/src/browser-automation/handler.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
||||
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
|
||||
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
|
||||
|
||||
interface BrowserAutomationClient {
|
||||
on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (message: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void;
|
||||
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
|
||||
}
|
||||
|
||||
export interface BrowserAutomationHandlerOptions {
|
||||
client: BrowserAutomationClient;
|
||||
serverId?: string;
|
||||
getHost?: () => DesktopHostBridge | null;
|
||||
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationHandler(
|
||||
options: BrowserAutomationHandlerOptions,
|
||||
): () => void {
|
||||
const getHost = options.getHost ?? getDesktopHost;
|
||||
return options.client.on("browser.automation.execute.request", (request) => {
|
||||
void handleBrowserAutomationRequest({
|
||||
client: options.client,
|
||||
getHost,
|
||||
request,
|
||||
serverId: options.serverId,
|
||||
ensureResidentBrowserWebview:
|
||||
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
|
||||
...(options.registrationWaitTimeoutMs !== undefined
|
||||
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
|
||||
: {}),
|
||||
...(options.registrationPollIntervalMs !== undefined
|
||||
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationDaemonClientHandler(
|
||||
client: unknown,
|
||||
options?: { serverId?: string },
|
||||
): () => void {
|
||||
return mountBrowserAutomationHandler({
|
||||
client: client as BrowserAutomationClient,
|
||||
...(options?.serverId ? { serverId: options.serverId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBrowserAutomationRequest(params: {
|
||||
client: BrowserAutomationHandlerOptions["client"];
|
||||
getHost: () => DesktopHostBridge | null;
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
client,
|
||||
getHost,
|
||||
request,
|
||||
serverId,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const browserHost = getHost()?.browser;
|
||||
const executeAutomationCommand = browserHost?.executeAutomationCommand;
|
||||
|
||||
if (request.command.command === "new_tab") {
|
||||
try {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: await openBrowserTabForRequest({
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await rememberAgentBrowserTarget({ request, browserHost });
|
||||
|
||||
if (!executeAutomationCommand) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await executeAutomationCommand(request);
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeBridgePayload(request.requestId, payload),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<BrowserAutomationResponsePayload> {
|
||||
const {
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "new_tab" }
|
||||
>;
|
||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
||||
if (!serverId || !workspaceId) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
|
||||
const url = command.args.url ?? "https://example.com";
|
||||
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
||||
kind: "browser",
|
||||
browserId,
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
if (request.agentId) {
|
||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
||||
}
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
const registered = await waitForBrowserRegistration({
|
||||
request,
|
||||
browserId,
|
||||
workspaceId,
|
||||
executeAutomationCommand: browserHost.executeAutomationCommand,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined
|
||||
? { pollIntervalMs: registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
if (!registered) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_timeout",
|
||||
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForBrowserRegistration(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
executeAutomationCommand: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationResponsePayload>;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
|
||||
while (Date.now() < deadline) {
|
||||
const payload = await params.executeAutomationCommand({
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: `${params.request.requestId}:list_tabs`,
|
||||
agentId: params.request.agentId,
|
||||
cwd: params.request.cwd,
|
||||
workspaceId: params.workspaceId,
|
||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
||||
});
|
||||
if (payload.ok && payload.result.command === "list_tabs") {
|
||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await delay(params.pollIntervalMs ?? 100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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(
|
||||
requestId: string,
|
||||
payload: BrowserAutomationResponsePayload,
|
||||
): BrowserAutomationResponsePayload {
|
||||
return { ...payload, requestId } as BrowserAutomationResponsePayload;
|
||||
}
|
||||
|
||||
function normalizeThrownBridgeError(
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
): BrowserAutomationFailurePayload {
|
||||
const typed = readTypedBrowserAutomationError(error);
|
||||
if (typed) {
|
||||
return browserAutomationFailure({ requestId, ...typed });
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered")) {
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
});
|
||||
}
|
||||
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unknown_error",
|
||||
message: message || "Desktop browser automation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
function readTypedBrowserAutomationError(
|
||||
value: unknown,
|
||||
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
|
||||
return null;
|
||||
}
|
||||
if (typeof record.message !== "string" || record.message.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: record.code as BrowserAutomationErrorCode,
|
||||
message: record.message,
|
||||
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function browserAutomationFailure(params: {
|
||||
requestId: string;
|
||||
code: BrowserAutomationErrorCode;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}): BrowserAutomationFailurePayload {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
ok: false,
|
||||
error: {
|
||||
code: params.code,
|
||||
message: params.message,
|
||||
retryable: params.retryable ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,14 +15,13 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
@@ -180,6 +179,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
desktopScroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
@@ -451,11 +455,13 @@ export interface AdaptiveModalSheetProps {
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Render the themed desktop-web scrollbar over the scroll area instead of the
|
||||
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
|
||||
*/
|
||||
webScrollbar?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -467,15 +473,18 @@ export function AdaptiveModalSheet({
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
webScrollbar = false,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const desktopScrollRef = useRef<ScrollView>(null);
|
||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
||||
enabled: webScrollbar && !isMobile,
|
||||
});
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
@@ -581,13 +590,22 @@ export function AdaptiveModalSheet({
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
ref={desktopScrollRef}
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={desktopScrollbar.onLayout}
|
||||
onScroll={desktopScrollbar.onScroll}
|
||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!webScrollbar}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{desktopScrollbar.overlay}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
@@ -602,18 +620,7 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone
|
||||
onFilesDropped={onFilesDropped}
|
||||
onGenericFilesDropped={onGenericFilesDropped}
|
||||
>
|
||||
{cardInner}
|
||||
</FileDropZone>
|
||||
) : (
|
||||
cardInner
|
||||
)}
|
||||
</View>
|
||||
<View style={desktopCardStyle}>{cardInner}</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
140
packages/app/src/components/assistant-fork-menu.tsx
Normal file
140
packages/app/src/components/assistant-fork-menu.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Split } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export type AssistantForkTarget = "tab" | "workspace";
|
||||
|
||||
interface AssistantForkMenuProps {
|
||||
onFork: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const ThemedSplit = withUnistyles(Split);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export const AssistantForkMenu = memo(function AssistantForkMenu({
|
||||
onFork,
|
||||
testID = "assistant-fork-menu",
|
||||
}: AssistantForkMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingTarget, setPendingTarget] = useState<AssistantForkTarget | null>(null);
|
||||
const isLocked = pendingTarget !== null;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next && pendingTarget !== null) return;
|
||||
setIsOpen(next);
|
||||
},
|
||||
[pendingTarget],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(target: AssistantForkTarget) => async () => {
|
||||
if (isLocked) return;
|
||||
setPendingTarget(target);
|
||||
try {
|
||||
await onFork(target);
|
||||
} finally {
|
||||
setPendingTarget(null);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[isLocked, onFork],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
|
||||
[isLocked],
|
||||
);
|
||||
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("message.actions.forkMenu")}</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
const forkIcon = useMemo(() => <ThemedSplit size={16} uniProps={foregroundColorMapping} />, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={styles.triggerSlot} collapsable={false}>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel={t("message.actions.forkMenu")}
|
||||
accessibilityRole="button"
|
||||
disabled={isLocked}
|
||||
style={triggerStyle}
|
||||
testID={`${testID}-trigger`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<ThemedSplit
|
||||
size={16}
|
||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" minWidth={220} side="bottom" testID={`${testID}-content`}>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "tab"}
|
||||
leading={forkIcon}
|
||||
onSelect={handleSelect("tab")}
|
||||
status={pendingTarget === "tab" ? "pending" : undefined}
|
||||
testID={`${testID}-new-tab`}
|
||||
>
|
||||
{t("message.actions.forkInNewTab")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "workspace"}
|
||||
leading={forkIcon}
|
||||
onSelect={handleSelect("workspace")}
|
||||
status={pendingTarget === "workspace" ? "pending" : undefined}
|
||||
testID={`${testID}-new-workspace`}
|
||||
>
|
||||
{t("message.actions.forkInNewWorkspace")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
triggerSlot: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./browser-webview-resident";
|
||||
|
||||
describe("resident browser webviews", () => {
|
||||
afterEach(() => {
|
||||
clearResidentBrowserWebviewsForTests();
|
||||
});
|
||||
|
||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
||||
const host = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
host.appendChild(webview);
|
||||
document.body.appendChild(host);
|
||||
|
||||
releaseResidentBrowserWebview("browser-a", webview);
|
||||
|
||||
expect(host.children).toHaveLength(0);
|
||||
expect(webview.isConnected).toBe(true);
|
||||
expect(webview.style.width).toBe("1280px");
|
||||
expect(webview.style.height).toBe("800px");
|
||||
|
||||
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", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-agent",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expect(webview?.isConnected).toBe(true);
|
||||
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
|
||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
|
||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-closed",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
removeResidentBrowserWebview("browser-closed");
|
||||
|
||||
expect(webview?.isConnected).toBe(false);
|
||||
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
|
||||
});
|
||||
});
|
||||
153
packages/app/src/components/browser-webview-resident.ts
Normal file
153
packages/app/src/components/browser-webview-resident.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
|
||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readDocument(): Document | null {
|
||||
return typeof document === "undefined" ? null : document;
|
||||
}
|
||||
|
||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const host = ownerDocument.createElement("div");
|
||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
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);
|
||||
return host;
|
||||
}
|
||||
|
||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||
return element as HTMLElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
||||
webview.style.display = "block";
|
||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
}
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
input: { browserId: string; initialUrl?: string | null },
|
||||
): void {
|
||||
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
|
||||
webview.setAttribute("allowpopups", "true");
|
||||
webview.setAttribute("spellcheck", "false");
|
||||
webview.setAttribute("autosize", "on");
|
||||
if (input.initialUrl) {
|
||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
url: string;
|
||||
}): HTMLElement | null {
|
||||
const browserId = trimNonEmpty(input.browserId);
|
||||
if (!browserId) {
|
||||
return null;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||
if (resident?.isConnected) {
|
||||
return resident;
|
||||
}
|
||||
|
||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
|
||||
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
if (!webview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
webview.remove();
|
||||
return;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
||||
}
|
||||
|
||||
export function removeResidentBrowserWebview(browserId: string): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
}
|
||||
|
||||
export function clearResidentBrowserWebviewsForTests(): void {
|
||||
for (const webview of residentWebviewsByBrowserId.values()) {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
@@ -80,6 +80,8 @@ interface CombinedModelSelectorProps {
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -87,6 +89,15 @@ interface CombinedModelSelectorProps {
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
/**
|
||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
||||
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
||||
* stops painting its own hover/pressed background and rounded corners. The
|
||||
* trigger itself owns the field visuals and reads hovered/pressed to show its
|
||||
* active state. Without this the trigger stays a content-width toolbar chip
|
||||
* (the composer's layout).
|
||||
*/
|
||||
triggerFill?: boolean;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
@@ -574,6 +585,7 @@ export function CombinedModelSelector({
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
triggerFill = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -693,14 +705,26 @@ export function CombinedModelSelector({
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
],
|
||||
[disabled, isOpen, renderTrigger],
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
||||
// hover/pressed state from the args, so the wrapper must not double-paint.
|
||||
if (triggerFill) {
|
||||
return [
|
||||
styles.trigger,
|
||||
styles.customTriggerWrapper,
|
||||
styles.triggerFill,
|
||||
disabled && styles.triggerDisabled,
|
||||
];
|
||||
}
|
||||
return [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
];
|
||||
},
|
||||
[disabled, isOpen, renderTrigger, triggerFill],
|
||||
);
|
||||
|
||||
const handleBackToAll = useCallback(() => {
|
||||
@@ -789,12 +813,16 @@ export function CombinedModelSelector({
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
})}
|
||||
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
hovered: Boolean(hovered),
|
||||
pressed,
|
||||
})
|
||||
}
|
||||
</Pressable>
|
||||
) : (
|
||||
<ComboboxTrigger
|
||||
@@ -886,6 +914,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
// Stretch the wrapper (and, via column + stretch, its single child) to the
|
||||
// full width of the field, with no background or rounding of its own.
|
||||
triggerFill: {
|
||||
alignSelf: "stretch",
|
||||
flexShrink: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: 0,
|
||||
},
|
||||
favoritesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
agent: AggregatedAgent;
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
@@ -257,6 +261,7 @@ function AgentItemsSection({
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
showHost,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -277,7 +282,7 @@ function AgentItemsSection({
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||
</CommandCenterAgentRow>
|
||||
);
|
||||
})}
|
||||
@@ -302,6 +307,8 @@ export function CommandCenter() {
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
// Host names only earn their space once results can span more than one host.
|
||||
const showHost = useHosts().length > 1;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
@@ -477,6 +484,7 @@ export function CommandCenter() {
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
showHost={showHost}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user