mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-18 07:12:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213bb3fe2f |
+95
-158
@@ -8,13 +8,9 @@ on:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref cancels the run still in progress, main included.
|
||||
# A full run costs about 200 runner-minutes against the org's 20 concurrent
|
||||
# jobs, and a superseded main run only answers a question the newer commit
|
||||
# answers again.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -58,10 +54,10 @@ jobs:
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
quality:
|
||||
name: quality
|
||||
format:
|
||||
name: format
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -72,63 +68,36 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
id: install
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
# Explicit status checks keep independent domains running after a failure.
|
||||
# A failed step still fails quality; no continue-on-error or success shim.
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
run: npm run format:check
|
||||
|
||||
lint:
|
||||
name: lint
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Lint lockfile
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Build relay
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build client dependencies
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
@@ -174,56 +143,43 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &server_capacity
|
||||
name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- &server_checkout
|
||||
uses: actions/checkout@v4
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- &server_fetch
|
||||
name: Fetch origin/main (worktree tests)
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- &server_agent_clis
|
||||
name: Install agent CLIs for provider tests
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- &server_build
|
||||
name: Build server dependencies
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env: &server_test_env
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- &server_plugin_tests
|
||||
name: Run built-in plugin tests
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- &server_hub_contract
|
||||
name: Run Hub CLI/daemon contract
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -232,41 +188,7 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server unit tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
|
||||
- *server_plugin_tests
|
||||
- *server_hub_contract
|
||||
|
||||
# Keep the serial integration chain off the Windows unit critical path.
|
||||
server-integration-windows:
|
||||
name: server-integration (windows-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server integration tests
|
||||
run: npm run test:integration --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
@@ -410,19 +332,52 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
name: sdk-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
name: playwright (shard 1/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
- name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -450,9 +405,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -465,74 +420,56 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/8)
|
||||
name: playwright (shard 2/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/8)
|
||||
name: playwright (shard 3/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/8)
|
||||
name: playwright (shard 4/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
playwright-6:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
steps: *playwright_test_steps
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
playwright-7:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
steps: *playwright_test_steps
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
playwright-8:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
steps: *playwright_test_steps
|
||||
- name: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
@@ -85,8 +85,6 @@ valknut-report.json/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts
|
||||
packages/protocol/codegen/generated/
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
-172
@@ -1,177 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.2 - 2026-10-17
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
|
||||
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
### Added
|
||||
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||
- Added a Send feedback button to the diff pane, so review comments reach an agent without opening its chat ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Added a fullscreen composer on phones ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170))
|
||||
- Added the sender's name to messages one agent sends another ([#6373](https://github.com/getpaseo/paseo/pull/6373))
|
||||
- Added Claude Mythos 5.1 when the installed Claude Code advertises it ([#6403](https://github.com/getpaseo/paseo/pull/6403))
|
||||
- Added option previews to Claude question cards ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Added `paseo usage` to show account quotas, balances, and reset times from the CLI ([#6474](https://github.com/getpaseo/paseo/pull/6474))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed text selection in replies on iOS and Android to cross paragraphs, headings, and lists ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Added a copy button to code blocks on iOS and Android ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Changed wide Markdown tables on iOS and Android to scroll sideways ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Changed phone layouts to a floating header ([#6431](https://github.com/getpaseo/paseo/pull/6431))
|
||||
- Moved New agent and New terminal on phones into the tab switcher ([#6431](https://github.com/getpaseo/paseo/pull/6431))
|
||||
- Moved workspace scripts on phones into ⋯ → Scripts, which shows what is running ([#7122](https://github.com/getpaseo/paseo/pull/7122))
|
||||
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
|
||||
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
|
||||
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
|
||||
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
|
||||
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
|
||||
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
|
||||
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Fixed plugins that import Paseo protocol types failing to install with "Could not resolve type dependency" ([#7023](https://github.com/getpaseo/paseo/pull/7023))
|
||||
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
|
||||
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
|
||||
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed iOS replies with list items that render blank, and text that keeps a stale width after a resize or rotation ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Fixed plugin hosts, the CLI, and the desktop app growing to about 1 GB of memory after running for 30 to 75 minutes ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed the daemon running out of memory during long OpenCode v2 reply or reasoning streams ([#6262](https://github.com/getpaseo/paseo/pull/6262))
|
||||
- Fixed archived ACP agents starting a provider process that kept running each time their timeline loaded ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed oversized attachments disconnecting the host in a loop instead of failing with a size error ([#6285](https://github.com/getpaseo/paseo/pull/6285))
|
||||
- Fixed Download in Files failing on hosts connected over relay, SSH, or a socket ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0))
|
||||
- Fixed Changes showing no diff lines with custom Git diff settings, such as `color.diff=always` or an external diff command ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
|
||||
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
|
||||
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
|
||||
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
|
||||
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
|
||||
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
|
||||
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
|
||||
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
|
||||
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
|
||||
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
|
||||
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
|
||||
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
|
||||
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
|
||||
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
|
||||
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Claude context meter counting only output tokens when a proxy reports input usage at the end of the response ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26))
|
||||
- Fixed the Claude context meter showing a 1M maximum after switching back to a 200k model ([#6814](https://github.com/getpaseo/paseo/pull/6814))
|
||||
- Fixed the context meter staying empty during the first turn on custom Claude `[1m]` models ([#6767](https://github.com/getpaseo/paseo/pull/6767))
|
||||
- Fixed a schedule never running again after one of its runs could not be recorded ([#6334](https://github.com/getpaseo/paseo/pull/6334))
|
||||
- Fixed schedules sharing a cron minute running a slot twice and skipping the next occurrence ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk))
|
||||
- Fixed Committed showing no changes for worktrees created from an existing branch ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax))
|
||||
- Fixed Discard and commit diffs in Changes for workspaces opened at a repository subdirectory ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed renamed and edited files showing every line as added in Changes ([#6559](https://github.com/getpaseo/paseo/pull/6559))
|
||||
- Fixed renamed files opened from a commit showing as new files ([#6895](https://github.com/getpaseo/paseo/pull/6895))
|
||||
- Fixed Changes showing no diff for filenames containing quotes, backslashes, or tabs ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Changes dropping removed lines that start with `--`, such as SQL comments ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed the Diff tab staying on a spinner after the first edit in a clean workspace ([#6745](https://github.com/getpaseo/paseo/pull/6745))
|
||||
- Fixed worktree creation failing after running `git init` in a project added before Git ([#6785](https://github.com/getpaseo/paseo/pull/6785))
|
||||
- Fixed new worktrees not opening the terminals configured in `paseo.json` after setup ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang))
|
||||
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
|
||||
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
|
||||
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
|
||||
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
|
||||
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
|
||||
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
|
||||
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
|
||||
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
|
||||
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
|
||||
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
|
||||
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
|
||||
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
|
||||
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
|
||||
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
|
||||
- Fixed shortcut recording saving the QWERTY key instead of the typed letter on layouts such as Dvorak ([#6652](https://github.com/getpaseo/paseo/pull/6652))
|
||||
- Fixed rebinding Jump to tab or Jump to workspace keeping only the pressed number instead of 1–9; record existing overrides again ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903))
|
||||
- Fixed Previous and Next workspace shortcuts stopping at workspace 9 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily))
|
||||
- Fixed unreadable Android status bar icons in the Light theme and after the system theme changes ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang))
|
||||
- Fixed the pairing QR code getting clipped on iOS and Android ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0))
|
||||
- Fixed a `write EPIPE` error dialog on Linux desktop when the launcher stops reading console output ([#6639](https://github.com/getpaseo/paseo/pull/6639))
|
||||
- Fixed `paseo permit allow` and `deny` reporting success over SSH while the request stayed pending ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt))
|
||||
- Fixed files and folders whose names start with two dots, like `..notes.txt`, being hidden and rejected ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Markdown previews dropping table rows after a `<` that isn't HTML, such as `List<Item>` ([#6370](https://github.com/getpaseo/paseo/pull/6370) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed a literal heading tag appearing when an HTML heading contains a linked image ([#6650](https://github.com/getpaseo/paseo/pull/6650))
|
||||
- Fixed Astro diffs with an empty `{}` expression failing to render ([#6763](https://github.com/getpaseo/paseo/pull/6763))
|
||||
- Fixed slash command argument hints missing from autocomplete for ACP providers ([#6920](https://github.com/getpaseo/paseo/pull/6920) by [@bwestlund17](https://github.com/bwestlund17))
|
||||
- Fixed terminal profiles configured with the `agy` icon showing a generic bot instead of the Antigravity icon ([#6938](https://github.com/getpaseo/paseo/pull/6938) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
|
||||
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
|
||||
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
|
||||
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
|
||||
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
|
||||
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
|
||||
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
|
||||
- Fixed the starting-ref picker not finding GitLab merge requests and issues by number ([#6525](https://github.com/getpaseo/paseo/pull/6525))
|
||||
- Fixed the starting-ref picker hiding change requests that match only in their description ([#6558](https://github.com/getpaseo/paseo/pull/6558))
|
||||
- Fixed @-mentions not finding plan files under `.cursor/plans` ([#6641](https://github.com/getpaseo/paseo/pull/6641) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
|
||||
- Fixed Paseo replacing a symlinked `config.json` with a regular file when saving settings ([#6023](https://github.com/getpaseo/paseo/pull/6023))
|
||||
- Fixed the published config schema rejecting valid settings such as `agentProfiles` and `plugins` ([#6621](https://github.com/getpaseo/paseo/pull/6621) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed duplicating a folder turning its relative symlinks into absolute links to the original ([#6771](https://github.com/getpaseo/paseo/pull/6771) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed renaming an untracked file like `[id].tsx` failing when a tracked file matches it as a pattern ([#6573](https://github.com/getpaseo/paseo/pull/6573) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed saving workspace files with long names failing with `ENAMETOOLONG` ([#6658](https://github.com/getpaseo/paseo/pull/6658) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed worktree creation and Changes failing for projects whose folder name ends in a space ([#6686](https://github.com/getpaseo/paseo/pull/6686))
|
||||
- Fixed merging a worktree when the base branch's worktree path contains a newline or ends in a space ([#6668](https://github.com/getpaseo/paseo/pull/6668) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo agent ls` listing only the first 200 agents ([#6033](https://github.com/getpaseo/paseo/pull/6033) by [@hteo1337](https://github.com/hteo1337))
|
||||
- Fixed `paseo worktree ls` and `paseo worktree archive` failing with "cwd or repoRoot is required" ([#6467](https://github.com/getpaseo/paseo/pull/6467) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed `paseo worktree ls` listing an archived worktree for up to 15 seconds ([#6545](https://github.com/getpaseo/paseo/pull/6545) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo logs --since` printing activity from before the requested timestamp ([#6514](https://github.com/getpaseo/paseo/pull/6514) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo logs --follow` splitting words across lines as text streams in ([#6528](https://github.com/getpaseo/paseo/pull/6528) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
- Fixed `paseo logs` printing reasoning after the reply that followed it ([#6546](https://github.com/getpaseo/paseo/pull/6546))
|
||||
- Fixed the daemon keeping files open after a canceled HTTP download ([#6567](https://github.com/getpaseo/paseo/pull/6567) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Linux desktop windows showing a generic icon instead of the Paseo icon ([#6645](https://github.com/getpaseo/paseo/pull/6645) by [@whme](https://github.com/whme), [@panta82](https://github.com/panta82))
|
||||
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
|
||||
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
|
||||
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
|
||||
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
|
||||
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project
|
||||
|
||||
## 0.11.3 - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
|
||||
- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper))
|
||||
|
||||
## 0.11.2 - 2026-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -49,8 +49,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
|
||||
@@ -57,7 +57,7 @@ Paseo is an open source agentic development environment for desktop, mobile, web
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="56"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
</tr>
|
||||
|
||||
@@ -12,7 +12,7 @@ provider deltas (every provider streams incrementally)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ source-item plugin transforms → Markdown presentation / tool grouping
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
```
|
||||
|
||||
@@ -29,8 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies a 32,000-character render cap on web; native keeps the complete message. The native renderer keeps closed blocks in a stable head and sends the paced suffix to a separate tail view during streaming; completion reunites them for selection. This stays inside the renderer, without promoting blocks into timeline history.
|
||||
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
@@ -39,27 +54,16 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
||||
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||
silently stops.
|
||||
- **Streaming cost must not grow with revealed history.** Reading older messages must not
|
||||
make the next reply progressively more expensive to display. Reuse unchanged history work
|
||||
while text arrives, and release obsolete window caches as the reader reveals more history;
|
||||
window caches must stay bounded while their source timeline remains loaded.
|
||||
|
||||
## Native parsing cost
|
||||
|
||||
The native library reparses and measures its entire `markdown` prop on each update. During streaming, the renderer splits source at an mdast block boundary and memoizes the head, so per-frame updates parse only the tail. Boundary discovery runs when a source line completes, rather than on every reveal frame. The unfinished line and final parsed block stay in the tail, preserving list continuation, setext headings, tables and open fences. Reference-dependent source and HTML retain a larger tail because later source can change their meaning. A single long block therefore still carries its full parsing cost.
|
||||
|
||||
Completion restores one native text view per Mermaid-delimited segment, so selection crosses prose paragraphs again. The head retains its trailing block margin while split. JS reveal remains the pacing source; native opacity animation stays disabled.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||||
- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
|
||||
|
||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||||
|
||||
|
||||
+4
-6
@@ -124,9 +124,7 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
|
||||
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
@@ -205,12 +203,12 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
|
||||
+1
-4
@@ -589,10 +589,7 @@ host, kinds, and ids. Opening a cached workspace uses exact workspace and projec
|
||||
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
||||
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
||||
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
||||
checkpoint are also applied in one transaction. Reads wait for accepted changes to reach storage,
|
||||
then retry only when a requested row changes during the read. Unrelated agent streams do not
|
||||
invalidate the read. Directory baseline replacements and host removal or renaming still invalidate
|
||||
the affected reads.
|
||||
checkpoint are also applied in one transaction.
|
||||
|
||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
||||
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
||||
|
||||
+11
-10
@@ -204,11 +204,10 @@ still-mounted content, paints its own surface under the safe areas, and turns
|
||||
the surface translation off. The composer's single tree keeps the editing
|
||||
surface at the same position in both modes, so the mounted editor, its native
|
||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
||||
the live input. A viewport crossing the compact breakpoint still remounts it:
|
||||
the app shell swaps its gesture wrapper and the workspace screen
|
||||
the live input. The one remount left is a viewport crossing the compact
|
||||
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
|
||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
||||
restores the text and the editing session restores the caret. Focus restoration
|
||||
is limited to mouse-driven UIs, using the same fine-pointer policy as autofocus.
|
||||
restores the text and the editing session restores the caret and focus intent.
|
||||
New workspace keys its sessions by draft in the app-level store for the same
|
||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
||||
@@ -218,12 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the
|
||||
user asks for the compact agent list or explorer, before the panel's motion
|
||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
||||
list must not clear the attention it shows. On mouse-driven UIs, focus restoration
|
||||
follows pane focus, not the active composer. Keying it on the active composer
|
||||
made sidebar dismissal look like a hidden-tab blur and refocused on close.
|
||||
Native and touch-web navigation must not replay old editor focus, including
|
||||
when blur arrives after pane deactivation or the editor unmounts before blur.
|
||||
Fullscreen toggles keep the editor mounted and need no focus restoration.
|
||||
list must not clear the attention it shows. Focus restoration follows pane
|
||||
focus, not the active composer. The editor keeps its focus intent only across
|
||||
a blur that lands while its pane is not the user's (a retained tab hidden, a
|
||||
sheet over it) and refocuses when the pane comes back. A blur while the pane is
|
||||
theirs drops the intent in every layout mode, and the keyboard the compact
|
||||
panels dismiss when they settle is such a blur, so closing the agent list or
|
||||
explorer never reopens the keyboard. Keying restoration on the active composer
|
||||
made that dismiss look like a hidden-tab blur and refocused on close.
|
||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Markdown rendering
|
||||
|
||||
Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and changelog content. It uses `react-native-enriched-markdown` on iOS, Android and web. Keep source adaptation and renderer configuration in `components/markdown`; consumers supply Markdown and, when needed, its file location.
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars.
|
||||
|
||||
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Comment HTML is normalized to Markdown before parsing; details show their summary and body expanded.
|
||||
|
||||
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
|
||||
|
||||
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
|
||||
|
||||
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
||||
|
||||
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
|
||||
|
||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
Follow [QA](qa.md) and [mobile testing](mobile-testing.md). Exercise chat, file preview, plan and PR content on all three platforms. Include cross-paragraph selection, progressive streaming, a file-link tap, code copy/highlighting, a table and a workspace image. On iPad, repeat preview selection after opening and closing Explorer and after rotation. Check web find, selection-copy and source-message navigation after DOM changes.
|
||||
+5
-22
@@ -300,33 +300,16 @@ done
|
||||
|
||||
### Android audio focus interruptions
|
||||
|
||||
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
|
||||
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
|
||||
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
|
||||
Run the microphone-privacy cases on Android 12 or newer.
|
||||
|
||||
Verify these distinct outcomes:
|
||||
|
||||
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
|
||||
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
|
||||
access resumes it. Starting dictation while access is disabled waits for the same recovery.
|
||||
- Returning from music controls resumes dictation without pausing music again. Cancelling a
|
||||
backgrounded recording must prevent the lifecycle callback from restarting it.
|
||||
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
|
||||
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
|
||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||
|
||||
```bash
|
||||
# Start voice mode in an existing composer, then background Paseo.
|
||||
adb shell am start -n sh.paseo/.MainActivity
|
||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||
adb emu gsm call 5551234
|
||||
# Foreground Paseo while the call is still ringing, then end the simulated call.
|
||||
adb emu gsm cancel 5551234
|
||||
# Foreground Paseo while the call is still ringing.
|
||||
```
|
||||
|
||||
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
|
||||
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
|
||||
Use browser/device automation to operate the app; the commands above only control emulator telephony.
|
||||
|
||||
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
|
||||
### Releasing the audio session when idle
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Native text selection
|
||||
|
||||
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
||||
|
||||
## Android inverted chat
|
||||
|
||||
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
|
||||
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
|
||||
into rotation with positive `scaleY`, so test the composed matrix when deciding
|
||||
whether those requests should move the viewport. Native tests must apply the
|
||||
list's transform through React Native's view manager; assigning `scaleY` directly
|
||||
misses that decomposition.
|
||||
|
||||
## Diagrams in lists and quotes
|
||||
|
||||
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
||||
|
||||
Reopening a list marker or quote prefix on the following fragment cannot place the diagram inside the original container or continue its quote rule. Preserving the group requires a native block renderer hook or a host that owns the whole group. Keep native diagrams at the top level until that exists; nested grouping remains a known limitation.
|
||||
|
||||
## Leading inline images
|
||||
|
||||
Native image decoders do not support SVG badges, so the renderer shows their alt text. Pending images share the web placeholder: a reserved image box with a small centred image glyph; confirmed acquisition or decoding failures show alt text.
|
||||
|
||||
The native library classifies an image from the text preceding it. A raster image at the start of a paragraph uses block sizing even when text follows; the inline size setting only affects images the library recognizes as inline. Correcting that classification requires a native library change and new app builds. JavaScript-only updates retain that classification until native app builds include a library fix.
|
||||
@@ -107,12 +107,6 @@ absent from the installed plugin list and source configuration; their client bun
|
||||
appear in the plugin catalog. Editing one in development requires a daemon restart.
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
A provider registration with `requiresConfiguration: true` participates only while its ID exists
|
||||
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
|
||||
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
|
||||
provider does not enter executable resolution or catalog discovery.
|
||||
|
||||
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
|
||||
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
|
||||
availability and a diagnostic after the daemon resolves the executable. Configure command,
|
||||
|
||||
@@ -6,9 +6,7 @@ The reason is mobile performance. A captured 353 KB provider snapshot cost about
|
||||
|
||||
## Runtime Path
|
||||
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It validates the envelope with a generated validator that checks only each session message's `type` tag, then runs the generated validator for that one message type and returns the validated data. Issue paths from the message validator, including nested union branches, are prefixed with `message`, so callers see the same issues as when one validator covered the whole envelope. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Keep one generated function per message type. zod-aot inlines every nested schema into the function it compiles, so compiling `WSOutboundMessageSchema` as a whole produced one 11 MB function. On V8 (daemon clients, plugin hosts, the CLI, and Electron), the Maglev tier compiled that function once it was hot, allocating about 800 MB and stalling for seconds, and compiled it again after each deoptimization. Hermes has no JIT, so mobile never showed it. `packages/protocol/tests/validation/ws-outbound.test.ts` fails if a generated function grows past 1 MB.
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||
|
||||
@@ -18,10 +16,11 @@ Provider model normalization is a parser-side compatibility shim in the client c
|
||||
|
||||
The protocol package owns generation.
|
||||
|
||||
- `packages/protocol/src/validation/ws-outbound-envelope.ts` defines the tag-only envelope schema and exposes the session message schemas that get their own validators.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` writes the zod-aot discovery entry and the runtime schema metadata, runs the exact-pinned compiler, and applies the small local compiler patches before generation. It also emits the `type` → validator map the boundary dispatches through.
|
||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||
- `packages/protocol/codegen/generated/ws-outbound.compile.ts`, `packages/protocol/src/generated/validation/ws-outbound.aot.ts`, and `packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts` are generated and gitignored. The metadata module lets generated code reach schema fallback/default values.
|
||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||
|
||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+10
-13
@@ -305,14 +305,12 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
|
||||
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
|
||||
|
||||
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
|
||||
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
@@ -336,7 +334,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Inspect the full release workflow, including distribute_ios, submit_android,
|
||||
# Inspect the full release workflow, including submit_ios, submit_android,
|
||||
# and submit_ios_for_review.
|
||||
npx eas workflow:view <workflow-run-id> --json
|
||||
|
||||
@@ -349,17 +347,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
|
||||
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
|
||||
For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
|
||||
- `build_ios` — iOS binary built
|
||||
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
|
||||
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
@@ -382,9 +380,8 @@ Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, external
|
||||
TestFlight distribution, Beta App Review submission, and store submission job
|
||||
for the release commit. For betas, confirm
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
@@ -399,7 +396,7 @@ Pattern:
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -683,7 +680,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
|
||||
@@ -251,8 +251,7 @@ do not imply local ownership or authorization. Capture the sender title at deliv
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
|
||||
labels for every Paseo MCP tool. The daemon
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
|
||||
|
||||
Generated
+96
-69
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -16265,6 +16265,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
@@ -17079,6 +17088,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
@@ -17104,6 +17122,17 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
@@ -30219,7 +30248,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -30231,7 +30259,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
@@ -30878,6 +30905,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
|
||||
"license": "Beerware",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||
@@ -30903,6 +30939,22 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-native-fit-image": "^1.5.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.2.0",
|
||||
"react-native": ">=0.50.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
|
||||
@@ -30968,6 +31020,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-uitextview": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz",
|
||||
"integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-unistyles": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
||||
@@ -36349,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36416,11 +36480,7 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mdast-util-from-markdown": "2.0.3",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"micromark-extension-gfm-table": "^2.1.2",
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -36430,14 +36490,15 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -37289,23 +37350,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
|
||||
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
@@ -37334,23 +37378,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/app/node_modules/react-native-enriched-markdown": {
|
||||
"version": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz",
|
||||
"integrity": "sha512-PyYAAVJAyjlcqHXgSnzxok13MDmyxs/nt46hHIOBdCc0prfaL2Ef6nYkZu7fFCeaETOJXd/xQr4n2/79I+gvdA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"katex": ">=0.16.0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"katex": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
|
||||
@@ -37469,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/server": "0.11.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37721,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37979,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38904,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38921,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38944,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/highlight": "0.12.0-beta.2",
|
||||
"@getpaseo/plugin": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/highlight": "0.11.2",
|
||||
"@getpaseo/plugin": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40119,7 +40146,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41727,7 +41754,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -23,16 +23,13 @@ jobs:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
distribute_ios:
|
||||
name: Distribute iOS to TestFlight
|
||||
submit_ios:
|
||||
name: Submit iOS
|
||||
needs: [build_ios]
|
||||
type: testflight
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
profile: production
|
||||
external_groups:
|
||||
- Paseo Beta
|
||||
submit_beta_review: true
|
||||
|
||||
submit_android:
|
||||
name: Submit Android
|
||||
@@ -44,7 +41,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
needs: [build_ios, submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1296.0)
|
||||
aws-sdk-core (3.257.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
rexml (~> 3.4, >= 3.4.2)
|
||||
aws-sdk-kms (1.132.0)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.233.2)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.7.20261007)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.3)
|
||||
logger
|
||||
faraday (2.15.0)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.5.0)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.241.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
erb (>= 4.0, < 7.0)
|
||||
excon (>= 1.0.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.5.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 3.1.0, < 4.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.109.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.3.1)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.69.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.4.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.1)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.1)
|
||||
googleauth (1.17.5)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.2)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.10.0)
|
||||
net-protocol (>= 0.3.0)
|
||||
uri (>= 0.11.1)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.3.1)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.1)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (3.1.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.3.0)
|
||||
unicode-emoji (~> 4.3)
|
||||
unicode-emoji (4.3.0)
|
||||
uri (1.1.2)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (~> 2.234)
|
||||
multi_json
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
|
||||
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
|
||||
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
|
||||
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
||||
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
|
||||
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
|
||||
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
|
||||
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
|
||||
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
|
||||
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
|
||||
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
|
||||
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
||||
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
||||
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.23
|
||||
@@ -1,24 +1,13 @@
|
||||
import {
|
||||
closeModelPicker,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
@@ -27,13 +16,8 @@ const ACP_PROVIDER = {
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { hopliteProvider?: boolean; plugins?: boolean };
|
||||
} | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { providers?: Record<string, unknown> } }>;
|
||||
getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -57,124 +41,3 @@ test.describe("ACP provider catalog", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-catalog-e2e",
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
|
||||
}, theme);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-added-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await removeHoplite(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-unsupported",
|
||||
port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
|
||||
const before = await client.getDaemonConfig();
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProviderArea(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
await expect(errorToast).toBeInViewport();
|
||||
expect((await client.getDaemonConfig()).config).toEqual(before.config);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-picker",
|
||||
});
|
||||
const profiles = await seedAgentProfiles([
|
||||
{ id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
|
||||
]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
|
||||
try {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-provider-mock")).toBeVisible();
|
||||
await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
|
||||
await closeModelPicker(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profiles.restore();
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
@@ -12,15 +12,12 @@ import {
|
||||
chooseAddProjectMethod,
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "../support/helpers/hosts";
|
||||
@@ -217,60 +214,6 @@ test.describe("Add Project command-center flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
|
||||
const hosts: IsolatedHostDaemon[] = [];
|
||||
try {
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 360 });
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(
|
||||
page,
|
||||
hosts.map((host, index) => ({
|
||||
serverId: host.serverId,
|
||||
port: host.port,
|
||||
label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
|
||||
})),
|
||||
{ primaryLabel: "Fleet 00" },
|
||||
);
|
||||
for (const host of hosts) {
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: host.serverId,
|
||||
endpoint: `localhost:${host.port}`,
|
||||
});
|
||||
}
|
||||
await openAddProjectHostSelection(page);
|
||||
await expect(
|
||||
addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
|
||||
).toBeVisible();
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
|
||||
// Filtering out the first host moves retained rows to new positions.
|
||||
await addProjectFlowInput(page).fill("scroll-host");
|
||||
await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Other host");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expectAddProjectResultVisible(page, "Fleet 00");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlow(page)).toContainText("Fleet 02");
|
||||
} finally {
|
||||
await Promise.all(hosts.map((host) => host.close()));
|
||||
}
|
||||
});
|
||||
|
||||
test("keyboard directory search adds the selected Project", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
@@ -298,59 +241,6 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("keyboard navigation keeps the selected row inside the visible results", async ({
|
||||
page,
|
||||
}) => {
|
||||
const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
|
||||
const rowPath = (index: number) =>
|
||||
path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
const results = page.getByTestId("add-project-flow-results");
|
||||
await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await navigateAddProjectResults(page, "ArrowUp", 12);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowUp", 1);
|
||||
await expectAddProjectResultVisible(page, rowPath(15));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
|
||||
await expect(results.getByRole("button")).toHaveCount(16);
|
||||
await expectAddProjectResultVisible(page, rowPath(0));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(12));
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
await expect(results.getByRole("button")).toHaveCount(17);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await page.keyboard.press("Enter");
|
||||
projectId = await expectOpenedProject(page, "scroll-probe-11");
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: "scroll-probe-11",
|
||||
projectPath: rowPath(11),
|
||||
});
|
||||
} finally {
|
||||
if (projectId) await removeCreatedProject(rowPath(11), projectId);
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1154,15 +1154,17 @@ test.describe("Agent message submission", () => {
|
||||
page,
|
||||
page.getByTestId("assistant-message").last(),
|
||||
);
|
||||
const assistantMessageCount = await page.getByTestId("assistant-message").count();
|
||||
const toolCallCount = await page.getByTestId("tool-call-badge").count();
|
||||
await composer.press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({
|
||||
hasText: "I'll take a look at the scroll anchor behavior you described.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await awaitToolCall(page, "Read");
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("assistant-message").count())
|
||||
.toBeGreaterThan(assistantMessageCount);
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("tool-call-badge").count())
|
||||
.toBeGreaterThan(toolCallCount);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
seedLongMockAgentTimeline,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
expectTimelinePromptVisible,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
||||
import {
|
||||
sampleStreamFrames,
|
||||
summarizeStreamSmoothness,
|
||||
@@ -109,40 +102,6 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
||||
});
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await submitMessage(page, "Stream a long answer after reading older messages.");
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
for (let sample = 0; sample < 3; sample += 1) {
|
||||
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
|
||||
const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
||||
const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
|
||||
const react = summarizeReactCommits(await readStreamReactCommits(page));
|
||||
await testInfo.attach(`revealed-history-stream-${sample}`, {
|
||||
body: JSON.stringify({ smoothness, react }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
|
||||
expect(smoothness.growthFrames).toBeGreaterThan(20);
|
||||
expect(react.count).toBeGreaterThan(20);
|
||||
expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readStreamReactCommits(
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
openDiagramFullscreen,
|
||||
expectFullscreenDiagram,
|
||||
} from "../support/helpers/diagram";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
@@ -26,10 +17,6 @@ import {
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
rememberTimelineViewport,
|
||||
scrollTimelinePromptIntoView,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
imageWorkspace: async ({ page: _page }, provide) => {
|
||||
@@ -110,11 +97,11 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.getByRole("button", { name: image.alt }).click();
|
||||
await page.getByRole("button", { name: "Open image attachment" }).click();
|
||||
const lightboxImage = page.getByTestId("attachment-lightbox-image");
|
||||
const canvas = page.getByTestId("attachment-lightbox-canvas");
|
||||
await expect(lightboxImage).toBeVisible();
|
||||
await page.getByTestId("zoomable-viewport-toolbar").hover();
|
||||
await canvas.hover();
|
||||
const transformedContent = canvas.locator(":scope > div").first();
|
||||
const initialBox = await transformedContent.boundingBox();
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
@@ -146,7 +133,7 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
@@ -156,34 +143,119 @@ test("reloading a timeline reserves image height without a load-time scroll jump
|
||||
fileName: "reload-geometry-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
try {
|
||||
delivery.release();
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image, delivery.url);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
|
||||
delivery.holdNextRequest();
|
||||
await page.reload();
|
||||
await delivery.waitUntilRequested();
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
|
||||
await scrollTimelinePromptIntoView(
|
||||
page,
|
||||
"image-history-turn-2: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
const beforeLoad = await rememberTimelineViewport(page);
|
||||
const reservedHeight = (await rendered.boundingBox())?.height;
|
||||
expect(reservedHeight).toBe(320);
|
||||
await page.addInitScript(
|
||||
({ accessibleName }) => {
|
||||
interface ImageGeometryEvent {
|
||||
atMs: number;
|
||||
complete: boolean;
|
||||
height: number;
|
||||
naturalHeight: number;
|
||||
naturalWidth: number;
|
||||
scrollTop: number;
|
||||
width: number;
|
||||
}
|
||||
const state = {
|
||||
accessibleName,
|
||||
events: [] as ImageGeometryEvent[],
|
||||
mutationObserver: null as MutationObserver | null,
|
||||
resizeObserver: null as ResizeObserver | null,
|
||||
target: null as Element | null,
|
||||
};
|
||||
const record = () => {
|
||||
const target = state.target;
|
||||
if (!target) return;
|
||||
const imageElement =
|
||||
target instanceof HTMLImageElement ? target : target.querySelector("img");
|
||||
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const event: ImageGeometryEvent = {
|
||||
atMs: performance.now(),
|
||||
complete: imageElement?.complete ?? false,
|
||||
height: rect.height,
|
||||
naturalHeight: imageElement?.naturalHeight ?? 0,
|
||||
naturalWidth: imageElement?.naturalWidth ?? 0,
|
||||
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
|
||||
width: rect.width,
|
||||
};
|
||||
const previous = state.events.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
previous.complete !== event.complete ||
|
||||
previous.height !== event.height ||
|
||||
previous.naturalHeight !== event.naturalHeight ||
|
||||
previous.naturalWidth !== event.naturalWidth ||
|
||||
previous.scrollTop !== event.scrollTop ||
|
||||
previous.width !== event.width
|
||||
) {
|
||||
state.events.push(event);
|
||||
}
|
||||
};
|
||||
const inspect = () => {
|
||||
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName,
|
||||
);
|
||||
if (!target || target === state.target) {
|
||||
record();
|
||||
return;
|
||||
}
|
||||
state.target = target;
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(record);
|
||||
state.resizeObserver.observe(target);
|
||||
record();
|
||||
};
|
||||
const start = () => {
|
||||
state.mutationObserver = new MutationObserver(inspect);
|
||||
state.mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["src", "style"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
inspect();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: typeof state;
|
||||
}
|
||||
).__paseoReloadImageGeometry = state;
|
||||
},
|
||||
{ accessibleName: image.alt },
|
||||
);
|
||||
|
||||
delivery.release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
expect((await rendered.boundingBox())?.height).toBe(reservedHeight);
|
||||
await expect.poll(() => rememberTimelineViewport(page)).toEqual(beforeLoad);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.reload();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: { events: Array<unknown> };
|
||||
}
|
||||
).__paseoReloadImageGeometry;
|
||||
if (!state) throw new Error("Reload image geometry observer was not installed");
|
||||
return state.events;
|
||||
});
|
||||
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
|
||||
const initialGeometry = geometryEvents.find((event) => event.height > 0);
|
||||
if (!initialGeometry)
|
||||
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
|
||||
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
|
||||
if (!expandedGeometry)
|
||||
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
|
||||
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
|
||||
initialGeometry.scrollTop,
|
||||
);
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
@@ -224,222 +296,3 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
|
||||
response: "stress-update-0",
|
||||
});
|
||||
});
|
||||
|
||||
test("a later reply reads new bytes at the same workspace image path", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
|
||||
const first = await createSmallAssistantPng(workspace, {
|
||||
alt: "Earlier screenshot",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, first);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await expectAssistantImageRendered(page, first);
|
||||
const second = await createSmallAssistantPng(workspace, {
|
||||
alt: "Updated screenshot",
|
||||
fileName: "screenshot.png",
|
||||
width: 2,
|
||||
height: 3,
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, second);
|
||||
await expectAssistantImageRendered(page, second);
|
||||
await expectAssistantImageRendered(page, first);
|
||||
});
|
||||
|
||||
test("pending assistant images reserve their decoded height", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Delayed remote image",
|
||||
fileName: "delayed.png",
|
||||
width: 32,
|
||||
height: 24,
|
||||
});
|
||||
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
const { url } = delivery;
|
||||
try {
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, url);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
|
||||
await delivery.waitUntilRequested();
|
||||
const pendingBox = await rendered.boundingBox();
|
||||
expect(pendingBox?.height).toBe(320);
|
||||
await rendered.click();
|
||||
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-image.png") });
|
||||
delivery.release();
|
||||
await expect(rendered).toHaveAttribute("src", url);
|
||||
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** Serve the fixture's real PNG bytes; release controls when the decoder can finish. */
|
||||
async function serveDelayedImage(file: string) {
|
||||
let release!: () => void;
|
||||
let ready: Promise<void>;
|
||||
let requested!: () => void;
|
||||
let requestStarted: Promise<void>;
|
||||
const holdNextRequest = () => {
|
||||
ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
requestStarted = new Promise<void>((resolve) => {
|
||||
requested = resolve;
|
||||
});
|
||||
};
|
||||
holdNextRequest();
|
||||
const server = createServer((_request, response) => {
|
||||
requested();
|
||||
void ready.then(() => {
|
||||
response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
|
||||
return createReadStream(file).pipe(response);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/image.png`,
|
||||
release: () => release(),
|
||||
holdNextRequest,
|
||||
waitUntilRequested: () => requestStarted,
|
||||
close: async () => {
|
||||
release();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("late workspace image completion preserves selected prose", async ({
|
||||
imageWorkspace,
|
||||
page,
|
||||
}) => {
|
||||
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
|
||||
const selected = await selectImageFixtureParagraphs(page);
|
||||
release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await expectSelectedParagraphsRetained(page, selected);
|
||||
});
|
||||
});
|
||||
|
||||
test("late workspace image completion keeps Mermaid fullscreen open", async ({
|
||||
imageWorkspace,
|
||||
page,
|
||||
}) => {
|
||||
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Done"]);
|
||||
release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await expectFullscreenDiagram(page, ["Start", "Done"]);
|
||||
});
|
||||
});
|
||||
|
||||
async function withPendingWorkspaceImage(
|
||||
page: Page,
|
||||
workspace: SeededWorkspace,
|
||||
run: (
|
||||
image: Awaited<ReturnType<typeof createSmallAssistantPng>>,
|
||||
release: () => void,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Late workspace image",
|
||||
fileName: "late.png",
|
||||
width: 32,
|
||||
height: 24,
|
||||
});
|
||||
const source = [
|
||||
"Paragraph one stays selected while the workspace image loads.",
|
||||
"Paragraph two remains in this reply.",
|
||||
"Paragraph three is the selection endpoint.",
|
||||
"```mermaid\nflowchart LR\n Start --> Done\n```",
|
||||
``,
|
||||
].join("\n\n");
|
||||
const agent = await createSettledMockAgent(workspace, "Delayed workspace read", source);
|
||||
await emitSettledAssistantImage(workspace.client, agent, image);
|
||||
const gate = await delayFileReadResponse(page, image.relativePath);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
gate.release();
|
||||
}
|
||||
};
|
||||
try {
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await gate.waitUntilHeld();
|
||||
await expectCompletedDiagram(page, ["Start", "Done"]);
|
||||
await run(image, release);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
type SelectionProofWindow = Window & { __lateImageSelection?: { start: Node; end: Node } };
|
||||
|
||||
async function selectImageFixtureParagraphs(page: Page): Promise<string> {
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await message
|
||||
.getByText("Paragraph one stays selected while the workspace image loads.", { exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
const bounds = await message.evaluate((root) => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let start: Text | undefined;
|
||||
let end: Text | undefined;
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode as Text;
|
||||
if (text.data.startsWith("Paragraph one stays")) start = text;
|
||||
if (text.data.startsWith("Paragraph three is")) end = text;
|
||||
}
|
||||
if (!start || !end) throw new Error("Selection paragraphs are missing");
|
||||
(window as SelectionProofWindow).__lateImageSelection = { start, end };
|
||||
const first = document.createRange();
|
||||
first.selectNodeContents(start);
|
||||
const last = document.createRange();
|
||||
last.selectNodeContents(end);
|
||||
return {
|
||||
first: first.getBoundingClientRect().toJSON(),
|
||||
last: last.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
await page.mouse.move(bounds.first.x + 1, bounds.first.y + 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.last.right - 1, bounds.last.bottom - 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("Paragraph one");
|
||||
expect(selected).toContain("Paragraph three");
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function expectSelectedParagraphsRetained(page: Page, selected: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const proof = (window as SelectionProofWindow).__lateImageSelection;
|
||||
return {
|
||||
startConnected: proof?.start.isConnected,
|
||||
endConnected: proof?.end.isConnected,
|
||||
text: window.getSelection()?.toString(),
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ startConnected: true, endConnected: true, text: selected });
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
reportScrollJumps,
|
||||
scrollCadences,
|
||||
withVariedTimeline,
|
||||
withStreamingReader,
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
@@ -135,58 +133,6 @@ test("scroll detector accounts for delayed wheel input and growth below the new
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
@@ -204,22 +150,3 @@ for (const cadence of scrollCadences) {
|
||||
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
|
||||
await expectImageSpaceReserved(page, testInfo);
|
||||
});
|
||||
|
||||
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
|
||||
await withStreamingReader(async (agent) => {
|
||||
const reading = await holdParagraphThroughCompletion(page, agent);
|
||||
expect(
|
||||
reading.textGrowth,
|
||||
"the held reader must observe an actively growing response",
|
||||
).toBeGreaterThan(1000);
|
||||
expect(
|
||||
reading.maxFollowGap,
|
||||
"follow-bottom must catch each streamed commit before the next frame",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
reading.maxDrift,
|
||||
"streaming must preserve the held paragraph's viewport position",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,7 @@ import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message on web before rendering markdown", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -73,14 +73,9 @@ const ASSISTANT_MARKDOWN = [
|
||||
].join("\n");
|
||||
|
||||
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
||||
"www.example.com stays",
|
||||
"[www.example.com](http://www.example.com) stays",
|
||||
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
"`https://example.com/generated` stays",
|
||||
"[`https://example.com/generated`](https://example.com/generated) stays",
|
||||
)
|
||||
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
|
||||
.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
@@ -103,7 +98,8 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate assistant rows by source-message identity.
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
@@ -177,7 +173,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const target = blocks.getByText(text);
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const frame = await blocks.locator("img").boundingBox();
|
||||
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
|
||||
const end = await target.evaluate((element, selectedText) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
@@ -196,8 +192,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
if (!frame) {
|
||||
throw new Error("Expected a rendered image");
|
||||
}
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.mouse.move(end.left + 1, end.y - 14);
|
||||
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
@@ -215,12 +210,18 @@ async function selectAssistantListItemFromMarker(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(listTag)
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(":scope > li")
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
@@ -238,7 +239,7 @@ async function selectAssistantListItemFromMarker(
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(element, 0);
|
||||
range.setStart(markerText, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
@@ -253,7 +254,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(tag)
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
.dblclick({ position: { x: 5, y: 5 } });
|
||||
}
|
||||
@@ -275,23 +276,39 @@ async function selectAssistantTextRange(
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
const text = nodes.map((node) => node.data).join("");
|
||||
const start = text.indexOf(selectedRange.startText);
|
||||
const end = text.indexOf(selectedRange.endText, start);
|
||||
if (start < 0 || end < 0)
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
let endOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
if (!startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
startNode = textNode;
|
||||
startOffset = offset;
|
||||
}
|
||||
}
|
||||
if (startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
endNode = textNode;
|
||||
endOffset = offset + selectedRange.endText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
if (!startNode || !endNode) {
|
||||
throw new Error(
|
||||
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
const boundary = (offset: number, isEnd = false): [Text, number] => {
|
||||
for (const node of nodes) {
|
||||
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
|
||||
offset -= node.length;
|
||||
}
|
||||
throw new Error("Selection extends beyond message");
|
||||
};
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(...boundary(start));
|
||||
range.setEnd(...boundary(end + selectedRange.endText.length, true));
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
@@ -411,12 +428,16 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
["em", "emphasized prose"],
|
||||
["s", "struck prose"],
|
||||
]) {
|
||||
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
|
||||
const formattedProse = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text });
|
||||
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
||||
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
||||
}
|
||||
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
|
||||
const inlineCode = assistantMessage
|
||||
.locator('[data-paseo-markdown-tag="code"]')
|
||||
.filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveAttribute("data-pmono", "");
|
||||
|
||||
await selectStructuredClipboardFixture(page);
|
||||
await copySelection(page);
|
||||
@@ -445,7 +466,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
||||
expect(clipboard.html).toContain(
|
||||
expect(clipboard.html).not.toContain(
|
||||
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
||||
);
|
||||
expect(clipboard.html).toContain('<code class="language-typescript">');
|
||||
|
||||
@@ -89,11 +89,9 @@ async function dismissStackedModelPickerWithBackdrop(page: Page) {
|
||||
.getByTestId("agent-controls-settings-list")
|
||||
.getByRole("button", { name: /Select model/ })
|
||||
.click();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeInViewport();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
|
||||
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
|
||||
// In CI, Gorhom retains the dismissed sheet below the viewport.
|
||||
// Dismissal is complete when it leaves the displayed surface.
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
}
|
||||
@@ -141,7 +139,7 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
@@ -20,10 +19,6 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
expectPromptsDrawnByPlugin,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
@@ -38,10 +33,9 @@ const STREAMED_RESPONSE = [
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message taller than the viewport, exercising navigation between distant
|
||||
// matches inside the same rendered row.
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const VIRTUALIZED_TURN_COUNT = 60;
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
@@ -125,49 +119,6 @@ async function closeFind(page: Page) {
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
|
||||
// The scrolling surface has no accessible name; use its existing test id.
|
||||
const viewport = page.getByTestId("agent-chat-scroll");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const before = await viewport.evaluate((element) => element.scrollTop);
|
||||
await page.keyboard.press(key);
|
||||
const direction = key === "PageUp" ? -1 : 1;
|
||||
await expect
|
||||
.poll(
|
||||
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
|
||||
)
|
||||
.toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-scroll-focus-",
|
||||
title: "Chat Find keyboard scrolling",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await closeFind(page);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
|
||||
await expectChatKeyboardScroll(page, "PageDown");
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await page.getByRole("button", { name: "Close Find", exact: true }).click();
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -222,7 +173,7 @@ async function highlightedMessage(page: Page) {
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
@@ -267,34 +218,6 @@ test("counts and steps through every match in the chat, not just the selected me
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-plugin-prompt-",
|
||||
title: "Chat Find plugin prompt",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await openChatFind(page);
|
||||
await query(page).fill("scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "plugin-prompt");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -399,11 +322,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
// The first-paragraph hit must remain searchable while the same message grows.
|
||||
// The only hit is in the first paragraph, which is no longer the row that grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.replyFinished();
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two paragraphs, one count across both.
|
||||
// One message, two Markdown blocks, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
await expectNextMatch(page, "2 of 2", "pair-needle");
|
||||
await closeFind(page);
|
||||
@@ -423,14 +346,12 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < VIRTUALIZED_TURN_COUNT; index++) {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(
|
||||
page.getByText(`Prompt number ${VIRTUALIZED_TURN_COUNT - 1}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
return agent;
|
||||
} catch (error) {
|
||||
@@ -439,8 +360,9 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
|
||||
}
|
||||
}
|
||||
|
||||
// Find must count distant paragraphs in one source message as well as messages
|
||||
// outside the mounted history window. Next must not skip a same-message hit.
|
||||
// Every block row of the selected message must be mounted for Find to see it, whatever
|
||||
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
|
||||
// from the count and Next walks off to the next message instead.
|
||||
test("counts every occurrence of a virtualized message and steps between them", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -448,9 +370,9 @@ test("counts every occurrence of a virtualized message and steps between them",
|
||||
const chat = await openChatWithVirtualizedTallMessage(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
|
||||
await expectFindStatus(page, "span-needle", "1 of 12");
|
||||
await expectHighlight(page, "span-needle");
|
||||
await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
|
||||
await expectNextMatch(page, "2 of 12", "span-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
|
||||
@@ -3,18 +3,6 @@ import { test, expect } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import {
|
||||
closeCompactWorkspaceMenu,
|
||||
goBackInMenu,
|
||||
menuRow,
|
||||
openCompactWorkspaceMenu,
|
||||
openRunningScriptPage,
|
||||
openScriptsPage,
|
||||
runScriptFromScriptsPage,
|
||||
scriptRow,
|
||||
scriptsMenuRow,
|
||||
} from "../support/helpers/compact-workspace-scripts";
|
||||
|
||||
const labels = {
|
||||
long: {
|
||||
@@ -50,13 +38,11 @@ async function openNamedWorkspace(page: Page, names: typeof labels.long) {
|
||||
}
|
||||
|
||||
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
|
||||
const menu = page.getByTestId("menu-button").filter({ visible: true });
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const project = page.getByTestId("workspace-header-subtitle");
|
||||
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
|
||||
const trigger = page.getByRole("button", { name: /Switch tabs/ });
|
||||
const measure = async () => ({
|
||||
menu: await menu.boundingBox(),
|
||||
title: await title.boundingBox(),
|
||||
project: await project.boundingBox(),
|
||||
badge: await badge.boundingBox(),
|
||||
@@ -68,21 +54,23 @@ async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean
|
||||
await page.screenshot({ path: info.outputPath("header.png") });
|
||||
await expect(async () => {
|
||||
const bounds = await measure();
|
||||
expect(bounds.menu).not.toBeNull();
|
||||
expect(bounds.title).not.toBeNull();
|
||||
expect(bounds.project).not.toBeNull();
|
||||
expect(bounds.badge).not.toBeNull();
|
||||
expect(bounds.icon).not.toBeNull();
|
||||
expect(bounds.trigger).not.toBeNull();
|
||||
// The labels sit between the menu button and the actions without overlapping either.
|
||||
expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
|
||||
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
|
||||
expect(bounds.title!.x).toBe(66);
|
||||
expect(bounds.project!.x).toBe(bounds.title!.x);
|
||||
expect(bounds.trigger!.width).toBe(32);
|
||||
expect(bounds.trigger!.height).toBe(32);
|
||||
// The action frame starts 7px inside its island; preserve the 8px gap before it.
|
||||
const contentRight = bounds.trigger!.x - 15;
|
||||
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
|
||||
expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
|
||||
}
|
||||
expect(bounds.project!.width).toBeGreaterThan(20);
|
||||
// Long labels truncate instead of squeezing the host icon.
|
||||
expect(bounds.icon!.width).toBe(bounds.icon!.height);
|
||||
expect(bounds.icon!.width).toBe(12);
|
||||
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
|
||||
expect(bounds.titleTruncated).toBe(long);
|
||||
expect(bounds.projectTruncated).toBe(long);
|
||||
@@ -106,87 +94,3 @@ for (const width of [320, 390]) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
|
||||
const WEB_SERVER =
|
||||
"node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
|
||||
|
||||
async function openWorkspaceWithScripts(page: Page) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "compact-scripts-",
|
||||
repo: {
|
||||
paseoConfig: {
|
||||
scripts: {
|
||||
lint: { type: "script", command: KEEP_ALIVE },
|
||||
web: { type: "service", command: WEB_SERVER },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Scripts menu check",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await openWorkspaceWithScripts(page);
|
||||
try {
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("nothing running: the Scripts row has no value", async () => {
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
|
||||
await runScriptFromScriptsPage(page, "lint");
|
||||
await expectOpenTabCount(page, 2);
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("two running scripts are counted on the root row", async () => {
|
||||
await runScriptFromScriptsPage(page, "web");
|
||||
await expectOpenTabCount(page, 3);
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("stopping a script keeps its page open in the stopped state", async () => {
|
||||
await openRunningScriptPage(page, "web");
|
||||
await expect(menuRow(page, "View service")).toBeVisible();
|
||||
await menuRow(page, "Stop").click();
|
||||
await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(menuRow(page, "Stop")).toHaveCount(0);
|
||||
|
||||
await goBackInMenu(page);
|
||||
await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
|
||||
await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("the root row names the one script still running", async () => {
|
||||
await openScriptsPage(page);
|
||||
await goBackInMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,7 +310,7 @@ async function sendFeedback(page: Page, count: number) {
|
||||
}
|
||||
|
||||
async function expectFeedbackSent(page: Page, recipient: string) {
|
||||
await expect(page.getByTestId("app-toast")).toHaveText(`Feedback sent to ${recipient}`);
|
||||
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -340,9 +340,6 @@ test.describe("send review feedback", () => {
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Please simplify this.");
|
||||
await captureFeedback(page, "single");
|
||||
await expect(
|
||||
page.getByText("Feedback sent to Composer diff stat", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
await test.step("Return to the untouched composer draft", async () => {
|
||||
await showWideLayout(page);
|
||||
@@ -378,7 +375,11 @@ test.describe("send review feedback", () => {
|
||||
).toBeDisabled();
|
||||
expect(gate.requestCount()).toBe(1);
|
||||
gate.accept();
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expect(
|
||||
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
composerLocator,
|
||||
blurComposerInput,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerDraft,
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
@@ -28,100 +25,6 @@ import {
|
||||
swipeOpenMobileAgentSidebar,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
rememberTimelinePromptPosition,
|
||||
seedLongMockAgentTimeline,
|
||||
waitForTimelineGeometryToSettle,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
test("clicking chat text or background focuses the feed for keyboard paging", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 8 });
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
const composer = composerLocator(page);
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await composer.fill("Keep this draft");
|
||||
await feed.getByText(agent.newestPrompt, { exact: true }).click();
|
||||
await expect(feed).toBeFocused();
|
||||
|
||||
const beforePaging = await readScrollMetrics(page);
|
||||
expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
await composer.click();
|
||||
await expectComposerFocused(page);
|
||||
await feed.click({ position: { x: 2, y: 40 } });
|
||||
await expect(feed).toBeFocused();
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeGreaterThan(beforePaging.viewportHeight / 2);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const readingPrompt = agent.prompts[4];
|
||||
await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
|
||||
const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
// The daemon finishing does not mean its response has reached the browser.
|
||||
await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await expectTimelinePromptPositionPreserved(page, readingPosition);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("focusing the feed preserves assistant text selection and code copy controls", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sentence = "Select this assistant sentence.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-focus-selection-",
|
||||
title: "Chat focus selection",
|
||||
initialPrompt: "Render text and code.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await composerLocator(page).fill("Keep this draft");
|
||||
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
|
||||
await expect(feed).toBeFocused();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
|
||||
|
||||
const code = feed.locator('[data-paseo-markdown-language="bash"]');
|
||||
await code.hover();
|
||||
await code.locator("[data-paseo-markdown-ignore]").click();
|
||||
await expect(feed).not.toBeFocused();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -170,59 +73,6 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-touch-remount-",
|
||||
title: "Touch remount",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await composerLocator(page).fill("Keep this draft through resize");
|
||||
await expectComposerFocused(page);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await expectComposerDraft(page, "Keep this draft through resize");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First touch chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await composerLocator(page).fill("Keep this touch draft");
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expectComposerDraft(page, "");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerDraft(page, "Keep this touch draft");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, "Keep this touch draft after return");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
|
||||
@@ -72,10 +72,10 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("button", { name, exact: true });
|
||||
const image = page.getByRole("img", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
await expect
|
||||
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
@@ -105,37 +105,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
title: "Screenshot file links",
|
||||
initialPrompt: "Show screenshots",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
|
||||
await openAgentRoute(page, session);
|
||||
await test.step("Open the linked screenshot", async () => {
|
||||
await openAssistantMarkdownLink(page, "Capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
|
||||
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
|
||||
await expect(page.getByText(/ENOENT/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
@@ -252,7 +221,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function openMarkdownPreview(page: Page) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-bar")
|
||||
.getByRole("button", { name: "Preview", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
async function followPreviewLink(page: Page, name: string) {
|
||||
await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByRole("link", { name, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Markdown preview folder links reveal Files and file links still open a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guides/guide.md"),
|
||||
"# Folder link destination\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "README.md"),
|
||||
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Guide");
|
||||
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Dot");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Bare");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
|
||||
});
|
||||
|
||||
async function openReportedFolderLink(page: Page, name: string) {
|
||||
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
|
||||
await link.click();
|
||||
}
|
||||
|
||||
async function expectFolderContents(page: Page) {
|
||||
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const folder of [
|
||||
"./docs/guides",
|
||||
"./docs/guides/",
|
||||
"./docs/reports.v1",
|
||||
"./.github/workflows",
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "folder-link-repro-",
|
||||
title: "Folder link reproduction",
|
||||
initialPrompt: "Show the folder link",
|
||||
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await openReportedFolderLink(page, "Open folder");
|
||||
await expectFolderContents(page);
|
||||
} finally {
|
||||
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings";
|
||||
|
||||
// Use an existing repository linked to the signed-in user's Hoplite project.
|
||||
const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../..");
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } },
|
||||
});
|
||||
|
||||
async function openHopliteSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise<void> {
|
||||
const models = await client.listProviderModels("hoplite");
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models).toEqual([]);
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({
|
||||
status: "ready",
|
||||
models: [],
|
||||
});
|
||||
await expect(page.getByText("No models detected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function refreshGlobalCatalog(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceModels(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerFocused(page);
|
||||
await openModelPicker(page);
|
||||
await page.getByTestId("sheet-header-back").click();
|
||||
await drillIntoProvider(page, "hoplite");
|
||||
}
|
||||
|
||||
test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({
|
||||
page,
|
||||
e2eWorkerClient,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "hoplite-discovery" });
|
||||
const created = await e2eWorkerClient.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "Hoplite discovery QA",
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const workspace = created.workspace!;
|
||||
try {
|
||||
await test.step("global settings succeed with no models, including refresh", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") });
|
||||
});
|
||||
|
||||
await test.step("the linked workspace discovers real models", async () => {
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
await testInfo.attach("workspace-models", {
|
||||
body: JSON.stringify(models, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await openWorkspaceModels(page, workspace.id);
|
||||
await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
|
||||
await test.step("workspace discovery does not populate the global catalog", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await e2eWorkerClient.removeProject(workspace.projectId);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
@@ -124,7 +126,6 @@ test.describe("Tab creation", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
@@ -189,18 +190,11 @@ test.describe("Tab creation", () => {
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
await clickNewChat(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -208,17 +202,12 @@ test.describe("Tab creation", () => {
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -306,6 +295,13 @@ test.describe("Tab creation", () => {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -343,4 +339,37 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
|
||||
async function openProjectHome(page: Page) {
|
||||
await page.goto("/open-project");
|
||||
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function scrollToHomeFooter(page: Page) {
|
||||
await page.mouse.move(160, 350);
|
||||
await page.mouse.wheel(0, 1500);
|
||||
}
|
||||
|
||||
async function expectHomeActionsReachable(page: Page) {
|
||||
const providers = page.getByTestId("open-project-setup-providers");
|
||||
const community = page.getByRole("button", { name: "Community", exact: true });
|
||||
await expect(providers).toBeInViewport({ ratio: 1 });
|
||||
await expect(community).toBeInViewport({ ratio: 1 });
|
||||
const providerBounds = await providers.boundingBox();
|
||||
const communityBounds = await community.boundingBox();
|
||||
expect(providerBounds).not.toBeNull();
|
||||
expect(communityBounds).not.toBeNull();
|
||||
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
|
||||
}
|
||||
|
||||
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openProjectHome(page);
|
||||
await scrollToHomeFooter(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
|
||||
});
|
||||
|
||||
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openProjectHome(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
|
||||
"-webkit-app-region",
|
||||
"none",
|
||||
);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send message",
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Literal shell commands and copyright markers must survive Markdown rendering.
|
||||
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||
// No unit test can catch that, because the defect lives in the JSX.
|
||||
//
|
||||
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||
// behavior is the same for every agent.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -19,7 +25,6 @@ test.describe("Plan card markdown", () => {
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard.locator(".enrm-text")).toBeVisible();
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
@@ -30,300 +35,3 @@ test.describe("Plan card markdown", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("chat and file preview share the library renderer and resolve file links", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withSharedMarkdownFiles(page, async () => {
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message).toHaveCount(1);
|
||||
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
|
||||
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
|
||||
await expect(message.locator("blockquote")).toContainText("A quote");
|
||||
await expect(message.getByRole("table")).toContainText("42");
|
||||
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
|
||||
await expect(message.getByRole("cell").nth(1)).toHaveText("");
|
||||
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
|
||||
await expect(message.locator("p div")).toHaveCount(0);
|
||||
await openSharedMarkdownFile(page, "README.md");
|
||||
const preview = page.getByTestId("markdown-preview-frame");
|
||||
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
|
||||
await expect(preview.getByRole("table")).toContainText("42");
|
||||
await returnToSharedMarkdownChat(page);
|
||||
await expectFileLinkTooltip(
|
||||
page,
|
||||
message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
|
||||
"packages/app/src/说明.md:2-3",
|
||||
);
|
||||
await openSharedMarkdownFile(page, "src/说明.md:2-3");
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "Space file");
|
||||
await expect(
|
||||
page.getByText("Explicit link opened the space filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "my%20file.ts");
|
||||
await expect(
|
||||
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("shared-renderer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
});
|
||||
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(path);
|
||||
}
|
||||
|
||||
async function withSharedMarkdownFiles(page: Page, run: () => Promise<void>) {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const source =
|
||||
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "shared-markdown-",
|
||||
title: "Shared Markdown",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
initialPrompt: "Show Markdown",
|
||||
});
|
||||
try {
|
||||
await writeFile(`${session.cwd}/README.md`, source);
|
||||
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
|
||||
await writeFile(
|
||||
`${session.cwd}/my%20file.ts`,
|
||||
"Inline code opened the literal percent filename.",
|
||||
);
|
||||
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
|
||||
await writeFile(
|
||||
`${session.cwd}/packages/app/src/说明.md`,
|
||||
"# Nested file\n\nUnicode suffix lookup succeeded.",
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
// A cold render must work even when a separate parser chunk cannot be fetched.
|
||||
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
|
||||
await openAgentRoute(page, session);
|
||||
await run();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function returnToSharedMarkdownChat(page: Page) {
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openSharedMarkdownFile(page: Page, name: string) {
|
||||
await returnToSharedMarkdownChat(page);
|
||||
await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
test("linked inline code keeps code typography and wraps long paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const longPath =
|
||||
"packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-code-style-",
|
||||
title: "Inline code style",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
|
||||
longPath +
|
||||
"`.",
|
||||
},
|
||||
initialPrompt: "Show inline code",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
|
||||
const measurements = await measureInlineCode(message);
|
||||
await testInfo.attach("inline-code-measurements", {
|
||||
body: JSON.stringify(measurements, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(measurements.codes).toHaveLength(4);
|
||||
const [plain, file, url, long] = measurements.codes;
|
||||
for (const linked of [file, url, long]) {
|
||||
expect(linked.fontSize).toBe(plain.fontSize);
|
||||
expect(linked.fontFamily).toBe(plain.fontFamily);
|
||||
expect(linked.backgroundColor).toBe(plain.backgroundColor);
|
||||
expect(linked.color).toBe(linked.anchorColor);
|
||||
expect(linked.color).not.toBe(plain.color);
|
||||
}
|
||||
expect(long.lines).toBeGreaterThan(1);
|
||||
expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureInlineCode(message: Locator) {
|
||||
return message.evaluate((element) => {
|
||||
// Constrain the paragraph to exercise an inline path at a line boundary.
|
||||
const paragraph = element.querySelector("p")!;
|
||||
paragraph.style.width = "300px";
|
||||
const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
|
||||
const style = getComputedStyle(code);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(code);
|
||||
const rects = Array.from(range.getClientRects());
|
||||
return {
|
||||
text: code.textContent,
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
|
||||
whiteSpace: style.whiteSpace,
|
||||
overflowWrap: style.overflowWrap,
|
||||
rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
|
||||
lines: new Set(rects.map((rect) => rect.top)).size,
|
||||
right: Math.max(...rects.map((rect) => rect.right)),
|
||||
};
|
||||
});
|
||||
return { codes, right: paragraph.getBoundingClientRect().right };
|
||||
});
|
||||
}
|
||||
|
||||
test("inline-code paths preserve stable recognition and file navigation", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
|
||||
const source =
|
||||
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
|
||||
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-path-regression-",
|
||||
title: "Inline path regression",
|
||||
initialPrompt: "Show file paths",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
});
|
||||
try {
|
||||
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
|
||||
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
|
||||
await writeFile(
|
||||
`${session.cwd}/src/index.ts`,
|
||||
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
|
||||
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
|
||||
for (const path of paths) {
|
||||
await openInlineRegressionFile(page, path);
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
|
||||
`Opened ${path}`,
|
||||
);
|
||||
}
|
||||
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
|
||||
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-content")
|
||||
.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(
|
||||
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-selectionBackground"),
|
||||
).not.toHaveCount(0);
|
||||
await testInfo.attach("inline-path-line-selection", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openInlineRegressionFile(page: Page, path: string) {
|
||||
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("assistant-message")
|
||||
.getByRole("link", { name: path, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Web links are styled inline by the library, so hover state has to restyle the anchor.
|
||||
test("markdown links underline on hover", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-link-hover-",
|
||||
title: "Markdown link hover",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
|
||||
},
|
||||
initialPrompt: "Show links",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
for (const name of ["the docs", "README"]) {
|
||||
const link = message.getByRole("link", { name, exact: true });
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
await link.hover();
|
||||
await expect(link).toHaveCSS("text-decoration-line", "underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// The web code block replaces the library's own block, which must keep its spacing.
|
||||
test("blocks around a web code block keep their spacing", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-block-spacing-",
|
||||
title: "Markdown block spacing",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
|
||||
},
|
||||
initialPrompt: "Show blocks",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("table")).toBeVisible();
|
||||
const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
|
||||
for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureBlockGaps(root: Locator) {
|
||||
return root.evaluate((element) => {
|
||||
const blocks = Array.from(element.children).map((block) => {
|
||||
// Wrappers with display: contents have no box; measure their first rendered child.
|
||||
let box: Element = block;
|
||||
while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
|
||||
box = box.firstElementChild;
|
||||
}
|
||||
const rect = box.getBoundingClientRect();
|
||||
return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
return blocks.slice(1).map((block, index) => ({
|
||||
between: `${blocks[index].text} -> ${block.text}`,
|
||||
gap: Math.round(block.top - blocks[index].bottom),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -317,47 +316,6 @@ export default function contribute(plugin) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
|
||||
const gatedBuildCommand = [
|
||||
'const fs = require("node:fs");',
|
||||
'fs.writeFileSync(process.argv[1], "started");',
|
||||
"const wait = () => {",
|
||||
" if (fs.existsSync(process.argv[2])) return;",
|
||||
" setTimeout(wait, 50);",
|
||||
"};",
|
||||
"wait();",
|
||||
].join("\n");
|
||||
|
||||
async function createGatedBuildRepository(
|
||||
root: string,
|
||||
gate: { started: string; release: string },
|
||||
): Promise<string> {
|
||||
const repository = path.join(root, "gated-repository");
|
||||
await mkdir(repository);
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "gated-build-plugin",
|
||||
description: "Holds the plugin lifecycle queue while its build waits",
|
||||
requirements: pluginRequirements,
|
||||
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
@@ -722,155 +680,3 @@ async function installPluginWithUntrustedDescription(
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
|
||||
function observePluginReload(page: Page) {
|
||||
const requests: number[] = [];
|
||||
const responses: number[] = [];
|
||||
const isSessionMessage = (payload: unknown, type: string): boolean => {
|
||||
if (typeof payload !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
return message?.type === type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
page.on("websocket", (socket) => {
|
||||
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
|
||||
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
|
||||
requestedAt: () => requests[0]!,
|
||||
requestCount: () => requests.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
|
||||
async function holdPluginLifecycleQueue(
|
||||
client: PluginEnvironment["client"],
|
||||
root: string,
|
||||
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
|
||||
const gate = {
|
||||
started: path.join(root, "build-started"),
|
||||
release: path.join(root, "build-release"),
|
||||
};
|
||||
const repository = await createGatedBuildRepository(root, gate);
|
||||
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
|
||||
// The marker wait below can outlive an early failure: a refused preparation, a dropped
|
||||
// connection, or a build command that exits before writing its marker. Handle that rejection now
|
||||
// so it never surfaces as an unhandled one, and drain the same promise in finish().
|
||||
const installDrained = install.catch(() => undefined);
|
||||
// Every path releases the gate and drains the install: a build still polling for its gate holds
|
||||
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
|
||||
const finish = async () => {
|
||||
await writeFile(gate.release, "go").catch(() => undefined);
|
||||
await installDrained;
|
||||
};
|
||||
try {
|
||||
// Whichever comes first decides the scenario: the build reports that it started, or the setup
|
||||
// fails with the daemon's own error instead of a bare marker timeout.
|
||||
await Promise.race([
|
||||
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
|
||||
install,
|
||||
]);
|
||||
} catch (error) {
|
||||
// Setup that never reached the build must not hand the caller a queue it cannot settle.
|
||||
await finish();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
release: () => writeFile(gate.release, "go"),
|
||||
settle: finish,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
|
||||
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
|
||||
const remaining = 66_000 - (Date.now() - requestedAt);
|
||||
if (remaining > 0) await page.waitForTimeout(remaining);
|
||||
}
|
||||
|
||||
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { client, directory } = pluginEnvironment;
|
||||
const pluginDirectory = await createDirectoryPlugin(
|
||||
directory,
|
||||
"e2e-plugin",
|
||||
"Exercises the complete local plugin lifecycle",
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { composerLocator, submitMessageWithButton } from "../support/helpers/composer";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { withPopoverTimelinePlugin } from "../support/helpers/plugin-timeline";
|
||||
|
||||
async function openPreviewWhileTyping(page: Page): Promise<void> {
|
||||
await composerLocator(page).focus();
|
||||
await page.getByRole("button", { name: "Hover plugin preview", exact: true }).hover();
|
||||
await expect(page.getByRole("dialog", { name: "Plugin preview" })).toBeVisible();
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function dismissPreview(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Plugin preview" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("Escape dismisses a plugin preview before interrupting the agent", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plugin-popover-",
|
||||
title: "Plugin preview keyboard regression",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
try {
|
||||
await withPopoverTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessageWithButton(page, "Show the plugin preview.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expect(page.getByRole("button", { name: "Hover plugin preview" })).toBeVisible();
|
||||
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
|
||||
const stop = page.getByRole("button", { name: "Stop agent", exact: true });
|
||||
await expect(stop).toBeVisible();
|
||||
|
||||
await openPreviewWhileTyping(page);
|
||||
await dismissPreview(page);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(stop).not.toBeVisible();
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -223,10 +223,7 @@ test.describe("Projects settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("edits worktree setup, navigates within the host, and removes a script", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
@@ -239,22 +236,15 @@ test.describe("Projects settings", () => {
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
await test.step("navigation stays inside the selected host", async () => {
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
await test.step("removing a script leaves the committed setup unchanged", async () => {
|
||||
await expectScriptRowCount(page, 1);
|
||||
await removeProjectScript(page, "dev");
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
@@ -269,10 +259,7 @@ test.describe("Projects settings", () => {
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
|
||||
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
@@ -281,23 +268,37 @@ test.describe("Projects settings", () => {
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
|
||||
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
|
||||
await openProjectEditSheet(page);
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
test("reopening the edit sheet seeds from the saved project", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
|
||||
await test.step("an icon can be saved without another name change", async () => {
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
await openProjectEditSheet(page);
|
||||
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
|
||||
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
});
|
||||
|
||||
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
|
||||
@@ -458,4 +459,31 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("project detail does not render a second host selector", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectHostContextHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,12 +116,28 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { killProcessTree } from "../support/helpers/spawn-node";
|
||||
import {
|
||||
buildAgentRoute,
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
continueToNextQuestion,
|
||||
@@ -141,204 +130,3 @@ test.describe("Question prompt pagination", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const questionDaemonMessage = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("attempt"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resolved"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
pendingRequestIds: z.array(z.string()),
|
||||
}),
|
||||
z.object({ type: z.literal("error"), error: z.string() }),
|
||||
]);
|
||||
|
||||
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
|
||||
|
||||
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
|
||||
|
||||
interface QuestionRetryControl {
|
||||
attempts: QuestionResponseAttempt[];
|
||||
resolutions: QuestionResolution[];
|
||||
releaseFirstResponse(): void;
|
||||
}
|
||||
|
||||
const retryTest = test.extend<{
|
||||
questionClient: QuestionRetryControl;
|
||||
}>({
|
||||
questionClient: async ({ page }, provide, testInfo) => {
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
|
||||
cwd: serverDir,
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
const output: string[] = [];
|
||||
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const attempts: QuestionResponseAttempt[] = [];
|
||||
const resolutions: QuestionResolution[] = [];
|
||||
child.on("message", (message: unknown) => {
|
||||
const parsed = questionDaemonMessage.parse(message);
|
||||
switch (parsed.type) {
|
||||
case "ready":
|
||||
ready.resolve(parsed);
|
||||
break;
|
||||
case "attempt":
|
||||
attempts.push(parsed);
|
||||
break;
|
||||
case "resolved":
|
||||
resolutions.push(parsed);
|
||||
break;
|
||||
case "error":
|
||||
ready.reject(new Error(parsed.error));
|
||||
break;
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Question daemon startup timed out")),
|
||||
20_000,
|
||||
);
|
||||
try {
|
||||
const daemon = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
port: daemon.port,
|
||||
repoPrefix: "question-retry-",
|
||||
title: "Question response retry",
|
||||
initialPrompt: "Emit synthetic questions.",
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
const serverId = daemon.serverId;
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "Question retry host",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.evaluate((extraHost) => {
|
||||
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
|
||||
}, host);
|
||||
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(child);
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("question-daemon", {
|
||||
body: output.join(""),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
retryTest(
|
||||
"failed question submission preserves answers and allows retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Preserve my answers after a failed submission.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(card.getByRole("progressbar")).toBeVisible();
|
||||
await expectQuestionPrimaryActionDisabled(page, "Submit");
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
|
||||
await expect(card.getByRole("tab").first()).toBeDisabled();
|
||||
await expect(card.getByRole("textbox")).not.toBeEditable();
|
||||
questionClient.releaseFirstResponse();
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "Behind feature flag");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue(
|
||||
"Preserve my answers after a failed submission.",
|
||||
);
|
||||
await submitQuestionAnswers(page);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
surface: "App",
|
||||
rollout: "Behind feature flag",
|
||||
success: "Preserve my answers after a failed submission.",
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
retryTest(
|
||||
"failed question dismissal allows an immediate retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
questionClient.releaseFirstResponse();
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Start with the internal team.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
|
||||
await expect(card.getByRole("alert")).toBeVisible();
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toEqual({
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,15 +21,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Show background",
|
||||
]);
|
||||
await closeMenuSheet(page);
|
||||
|
||||
await page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.click();
|
||||
|
||||
@@ -29,16 +29,6 @@ test.describe("Sidebar project filter", () => {
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
"Show background",
|
||||
]);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
|
||||
@@ -504,17 +504,6 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Harmless commands: this journey opens the profile menu without launching them.
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
daemon: {
|
||||
terminalProfiles: [
|
||||
{ id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
|
||||
{ id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
|
||||
{ id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function profileIcon(page: Page, name: string) {
|
||||
return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
|
||||
}
|
||||
|
||||
async function expectBundledArtwork(page: Page, name: string, artwork: string) {
|
||||
const icon = profileIcon(page, name);
|
||||
await expect(icon).toBeVisible();
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
}, artwork);
|
||||
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
|
||||
for (const [index, path] of expectedPaths.entries()) {
|
||||
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
|
||||
}
|
||||
await expect(icon).toHaveAttribute("width", "14");
|
||||
await expect(icon).toHaveAttribute("height", "14");
|
||||
const terminalColor = await page
|
||||
.getByRole("menuitem", { name: "Terminal", exact: true })
|
||||
.locator("svg")
|
||||
.evaluate((element) => getComputedStyle(element).stroke);
|
||||
await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
|
||||
}
|
||||
|
||||
async function expectUnknownArtworkFallback(page: Page) {
|
||||
const icon = profileIcon(page, "Unknown");
|
||||
await expect(icon).toBeVisible();
|
||||
await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await openLaunchMenu(page);
|
||||
await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
|
||||
await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
|
||||
await expectUnknownArtworkFallback(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -101,40 +100,6 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function installReversedUsageRefresh(page: Page) {
|
||||
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
||||
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
||||
return installUsageReportsFixture(page, {
|
||||
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
|
||||
});
|
||||
}
|
||||
|
||||
async function openUsageSummaryAtWidth(page: Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
if (width === 390) await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
}
|
||||
|
||||
async function expectUsageProviderOrder(page: Page, summary: string[]) {
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
|
||||
"opencode plan",
|
||||
"openai plan",
|
||||
]);
|
||||
await expectPinnedUsage(page, summary);
|
||||
}
|
||||
|
||||
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
|
||||
const requestCount = usage.listRequests().length;
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
@@ -187,21 +152,6 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const usage = await installReversedUsageRefresh(page);
|
||||
await openUsageSummaryAtWidth(page, width);
|
||||
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
||||
|
||||
await refreshUsageAndWaitForReports(page, usage);
|
||||
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
||||
});
|
||||
}
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
@@ -589,69 +539,6 @@ async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const compact of [false, true]) {
|
||||
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
|
||||
});
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active quota" },
|
||||
});
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
scrollUsage,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
@@ -146,9 +146,7 @@ test.describe("Usage item", () => {
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll between their first and last windows on ${size}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
@@ -163,10 +161,10 @@ test.describe("Usage item", () => {
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsageToWindow(page, "Window 20");
|
||||
await scrollUsage(page, 5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsageToWindow(page, "Window 1");
|
||||
await scrollUsage(page, -5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
submitAgentChatAndExpectCwd,
|
||||
} from "../support/helpers/workspace-lifecycle";
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
@@ -20,9 +19,8 @@ test.describe("Workspace lifecycle", () => {
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("submitting a draft creates an agent in the project root", async () => {
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
await submitAgentChatAndExpectCwd(page, workspace);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# Native compact workspace scripts flow
|
||||
|
||||
`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed.
|
||||
|
||||
The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`:
|
||||
|
||||
```json
|
||||
{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } }
|
||||
```
|
||||
|
||||
The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137
|
||||
```
|
||||
@@ -1,45 +0,0 @@
|
||||
context platform=android timeout=120000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env SCRIPT=worker
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 45000
|
||||
wait stable 1000 15000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-stop\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-run\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-restart\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 15000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-stop\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
|
||||
back
|
||||
wait stable 1000 15000
|
||||
close
|
||||
@@ -1,18 +0,0 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
|
||||
# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
|
||||
open sh.paseo.debug
|
||||
wait "editable=true" 60000
|
||||
fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
|
||||
press "label=\"Fullscreen\""
|
||||
wait "label=\"Exit fullscreen\" role=button" 5000
|
||||
is visible "label=\"Exit fullscreen\" role=button"
|
||||
press "label=\"Exit fullscreen\" role=button"
|
||||
wait "label=\"Fullscreen\" role=button" 5000
|
||||
is visible "label=\"Fullscreen\" role=button"
|
||||
press "label=\"Fullscreen\""
|
||||
wait "label=\"Exit fullscreen\" role=button" 5000
|
||||
is visible "label=\"Exit fullscreen\" role=button"
|
||||
press "label=\"Exit fullscreen\" role=button"
|
||||
wait "label=\"Fullscreen\" role=button" 5000
|
||||
is visible "label=\"Fullscreen\" role=button"
|
||||
@@ -1,13 +0,0 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
# A released drag must not swallow the next tap: navigation closes the sidebar.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
close
|
||||
@@ -52,11 +52,13 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise<void
|
||||
await expect(messages).toHaveCount(count);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const message = messages.nth(index);
|
||||
await expect(message).toHaveText("Current temperature: 25°C. Read Paseo docs.");
|
||||
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
|
||||
await expect(
|
||||
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
|
||||
).toHaveAttribute("href", "https://example.com/docs");
|
||||
await expect(message.locator("strong")).toHaveText("Current temperature");
|
||||
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
|
||||
"Current temperature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,26 +90,3 @@ export async function expectNewWorkspaceForAddedProject(
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function navigateAddProjectResults(
|
||||
page: Page,
|
||||
key: "ArrowDown" | "ArrowUp",
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
export async function expectAddProjectResultVisible(page: Page, text: string): Promise<void> {
|
||||
const results = page.getByTestId("add-project-flow-results");
|
||||
const row = results.getByRole("button").filter({ has: page.getByText(text, { exact: true }) });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewport = await results.boundingBox();
|
||||
const bounds = await row.boundingBox();
|
||||
if (!viewport || !bounds) return false;
|
||||
return (
|
||||
bounds.y >= viewport.y - 1 && bounds.y + bounds.height <= viewport.y + viewport.height + 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -575,25 +575,20 @@ export async function holdAssistantStream(page: Page, agentId: string) {
|
||||
});
|
||||
});
|
||||
|
||||
const forwardUntil = async (done: (text: string) => boolean): Promise<void> => {
|
||||
while (!done(forwardedText)) {
|
||||
const frame = pending.shift();
|
||||
if (!frame) {
|
||||
await new Promise<void>((resolve) => {
|
||||
notifyFrame = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
forwardedText += frame.text;
|
||||
frame.forward();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
waitForInitialTimeline: () => initialTimeline,
|
||||
showUntil: (text: string) => forwardUntil((forwarded) => forwarded.includes(text)),
|
||||
async showThrough(prefix: string): Promise<void> {
|
||||
await forwardUntil((text) => text.length >= prefix.length);
|
||||
while (forwardedText.length < prefix.length) {
|
||||
const frame = pending.shift();
|
||||
if (!frame) {
|
||||
await new Promise<void>((resolve) => {
|
||||
notifyFrame = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
forwardedText += frame.text;
|
||||
frame.forward();
|
||||
}
|
||||
if (forwardedText !== prefix) {
|
||||
throw new Error(
|
||||
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
|
||||
|
||||
@@ -26,13 +26,16 @@ export interface AssistantImageFixture {
|
||||
|
||||
export async function createSmallAssistantPng(
|
||||
workspace: SeededWorkspace,
|
||||
input: { alt: string; fileName: string; width?: number; height?: number },
|
||||
input: { alt: string; fileName: string },
|
||||
): Promise<AssistantImageFixture> {
|
||||
const width = input.width ?? 1;
|
||||
const height = input.height ?? 1;
|
||||
const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
|
||||
return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
|
||||
return {
|
||||
alt: input.alt,
|
||||
height: 1,
|
||||
relativePath: input.fileName,
|
||||
size: SMALL_PNG.byteLength,
|
||||
width: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNearTenMegabyteAssistantPng(
|
||||
@@ -58,7 +61,6 @@ export async function createNearTenMegabyteAssistantPng(
|
||||
export async function createSettledMockAgent(
|
||||
workspace: SeededWorkspace,
|
||||
title: string,
|
||||
markdown?: string,
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
@@ -67,7 +69,6 @@ export async function createSettledMockAgent(
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
featureValues: markdown === undefined ? undefined : { mockAssistantResponse: markdown },
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
@@ -123,7 +124,7 @@ export async function expectAssistantImageRendered(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
const rendered = page.getByRole("img", { name: image.alt }).first();
|
||||
await expect(rendered).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () =>
|
||||
@@ -171,14 +172,14 @@ export async function expectAssistantImageNotMounted(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: image.alt })).toHaveCount(0);
|
||||
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function userPagesUntilAssistantImageRenders(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("button", { name: image.alt });
|
||||
const rendered = page.getByRole("img", { name: image.alt });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if ((await rendered.count()) > 0) {
|
||||
await expectAssistantImageRendered(page, image);
|
||||
@@ -252,8 +253,8 @@ async function beginVisibleImageStabilityObservation(
|
||||
);
|
||||
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||
if (imageTab?.getAttribute("aria-selected") !== "true") return;
|
||||
const imageVisible = Array.from(document.querySelectorAll("img[alt]")).some(
|
||||
(element) => element.getAttribute("alt") === accessibleName && isVisible(element),
|
||||
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
|
||||
);
|
||||
record.missingSeen ||= !imageVisible;
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The compact ⋯ sheet. Its trigger shares "Workspace actions" with the sidebar row kebabs. */
|
||||
export function compactWorkspaceMenu(page: Page): Locator {
|
||||
return page.getByTestId("workspace-header-menu-content");
|
||||
}
|
||||
|
||||
export async function openCompactWorkspaceMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(compactWorkspaceMenu(page)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export function menuRow(page: Page, name: string | RegExp): Locator {
|
||||
return compactWorkspaceMenu(page).getByRole("menuitem", { name });
|
||||
}
|
||||
|
||||
/** The root row that leads to the Scripts page; its accessible name carries its value. */
|
||||
export function scriptsMenuRow(page: Page): Locator {
|
||||
return menuRow(page, /^Scripts/);
|
||||
}
|
||||
|
||||
/** A row on the Scripts page. A running script's name is followed by "Running". */
|
||||
export function scriptRow(page: Page, scriptName: string): Locator {
|
||||
return menuRow(page, new RegExp(`^${scriptName}`));
|
||||
}
|
||||
|
||||
export async function openScriptsPage(page: Page): Promise<void> {
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await scriptsMenuRow(page).click();
|
||||
await expect(scriptsMenuRow(page)).toHaveCount(0);
|
||||
await expect(backButton(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openRunningScriptPage(page: Page, scriptName: string): Promise<void> {
|
||||
await openScriptsPage(page);
|
||||
await scriptRow(page, scriptName).click();
|
||||
await expect(menuRow(page, "Stop")).toBeVisible();
|
||||
}
|
||||
|
||||
/** Taps a stopped script on the Scripts page, which runs it and closes the sheet. */
|
||||
export async function runScriptFromScriptsPage(page: Page, scriptName: string): Promise<void> {
|
||||
await openScriptsPage(page);
|
||||
await scriptRow(page, scriptName).click();
|
||||
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function goBackInMenu(page: Page): Promise<void> {
|
||||
await backButton(page).click();
|
||||
}
|
||||
|
||||
export async function closeCompactWorkspaceMenu(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 12 } });
|
||||
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
function backButton(page: Page): Locator {
|
||||
return compactWorkspaceMenu(page).getByRole("button", { name: "Back" });
|
||||
}
|
||||
@@ -80,6 +80,13 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the New tab button is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -125,6 +132,13 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -231,46 +231,3 @@ export async function attachPromptTimelineEvidence(page: Page, info: TestInfo):
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
const POPOVER_CLIENT_SOURCE = `import React, { useEffect, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
function Preview() {
|
||||
const panel = useRef(null);
|
||||
useEffect(() => {
|
||||
const close = (event) => {
|
||||
if (event.key === "Escape" && panel.current.matches(":popover-open")) {
|
||||
panel.current.hidePopover();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", close, true);
|
||||
return () => document.removeEventListener("keydown", close, true);
|
||||
}, []);
|
||||
return <div>
|
||||
<button onMouseEnter={() => panel.current.showPopover()}>Hover plugin preview</button>
|
||||
<div ref={panel} popover="manual" role="dialog" aria-label="Plugin preview">Plugin hover preview</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "preview", query: { itemType: "user_message" },
|
||||
transform({ item }) {
|
||||
if (item.text !== "Show the plugin preview.") return;
|
||||
return { items: [{ type: "plugin", kind: "preview", version: 1, data: {} }] };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "preview", version: 1, schema: z.object({}), Component: Preview,
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
/** Installs a timeline preview that owns Escape through the DOM Popover API. */
|
||||
export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promise<void> {
|
||||
await withDirectoryPlugin({
|
||||
pluginId: "timeline-popover-e2e",
|
||||
source: POPOVER_CLIENT_SOURCE,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
|
||||
@@ -45,8 +45,11 @@ export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): P
|
||||
|
||||
export async function expectUnfinishedBold(page: Page): Promise<void> {
|
||||
const message = page.getByTestId("assistant-message").last();
|
||||
await expect(message.locator(".enrm-text")).toContainText("Bold");
|
||||
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
|
||||
await expect(bold).toContainText("Bold");
|
||||
await expect(message).not.toContainText("stays bold");
|
||||
await expect(bold).toHaveCSS("font-weight", "500");
|
||||
await expect(message).not.toContainText("*");
|
||||
}
|
||||
|
||||
export async function expectUnfinishedLink(
|
||||
@@ -58,6 +61,7 @@ export async function expectUnfinishedLink(
|
||||
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
|
||||
await expect(message).toContainText("Paseo docs");
|
||||
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
|
||||
await expect(message).not.toContainText("[");
|
||||
await expect(message).not.toContainText("https:");
|
||||
await captureMarkdown(page, testInfo, "unfinished-link");
|
||||
}
|
||||
@@ -91,5 +95,8 @@ async function expectCompletedMarkdown(page: Page): Promise<void> {
|
||||
await expect(
|
||||
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
|
||||
).toHaveAttribute("href", "https://example.com/documentation");
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "700");
|
||||
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
|
||||
"font-weight",
|
||||
"500",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapsho
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
||||
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.evaluate(
|
||||
(element) =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { seedMockAgentWorkspace, openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
|
||||
import { holdAssistantStream } from "./agent-timeline-gate";
|
||||
|
||||
export const scrollCadences = [
|
||||
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
|
||||
@@ -45,120 +44,6 @@ interface TimelinePage {
|
||||
at: number;
|
||||
}
|
||||
|
||||
export async function withStreamingReader(run: (agent: MockAgentWorkspace) => Promise<void>) {
|
||||
const section = "Read this paragraph while the response continues below. ".repeat(8);
|
||||
const response = [
|
||||
"```mermaid\nflowchart LR\n Start --> Review --> Done\n```",
|
||||
"```mermaid\nflowchart LR\n Nested --> Finished\n```",
|
||||
...Array.from({ length: 40 }, (_, index) => `## Long section ${index}\n\n${section}`),
|
||||
"READER-STREAM-SETTLED",
|
||||
].join("\n\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "held-stream-reader-",
|
||||
title: "Held streaming paragraph",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: response,
|
||||
mockStreamingAssistantIntervalMs: 1,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await run(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export async function holdParagraphThroughCompletion(page: Page, agent: MockAgentWorkspace) {
|
||||
const stream = await holdAssistantStream(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await page.getByRole("textbox", { name: "Message agent...", exact: true }).waitFor();
|
||||
const scroll = page.getByTestId("agent-chat-scroll");
|
||||
const following = await scroll.evaluateHandle((viewport) => {
|
||||
const result = { stopped: false, maxGap: 0 };
|
||||
const frame = () => {
|
||||
if (result.stopped) return;
|
||||
result.maxGap = Math.max(
|
||||
result.maxGap,
|
||||
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
|
||||
);
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
return result;
|
||||
});
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream the mixed response.");
|
||||
await stream.showUntil("Long section 12\n");
|
||||
await page
|
||||
.getByRole("heading", { name: "Long section 12", exact: true })
|
||||
.waitFor({ state: "attached" });
|
||||
// Exercise a reader whose producer has already finished; delivery must be gated.
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expect(page.getByTestId("mermaid-viewport")).toHaveCount(2);
|
||||
await expect(page.getByTestId("mermaid-viewport").nth(0)).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("mermaid-viewport").nth(1)).toHaveCSS("opacity", "1");
|
||||
const maxFollowGap = await following.evaluate((result) => {
|
||||
result.stopped = true;
|
||||
return result.maxGap;
|
||||
});
|
||||
const bounds = await scroll.boundingBox();
|
||||
if (!bounds) throw new Error("Reader viewport is missing");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.wheel(0, -1600);
|
||||
await page.waitForTimeout(150);
|
||||
const paragraph = page.getByRole("heading", { name: "Long section 3", exact: true });
|
||||
await paragraph.waitFor({ state: "attached" });
|
||||
const recording = await paragraph.evaluateHandle((heading) => {
|
||||
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
|
||||
throw new Error("The stream completed before the reader detached");
|
||||
}
|
||||
const viewport = document.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]')!;
|
||||
const clip = viewport.getBoundingClientRect();
|
||||
viewport.scrollTop += heading.getBoundingClientRect().y - (clip.top + clip.height / 2);
|
||||
const baseline = heading.getBoundingClientRect().y;
|
||||
const initialTextLength = heading.closest('[data-testid="assistant-message"]')!.textContent!
|
||||
.length;
|
||||
const result = { maxDrift: 0, retained: true, textGrowth: 0, done: false };
|
||||
let stableAt = performance.now();
|
||||
let signature = "";
|
||||
const frame = (now: number) => {
|
||||
result.retained &&= heading.isConnected;
|
||||
const current = [...document.querySelectorAll("h2")].find(
|
||||
(n) => n.textContent === "Long section 3",
|
||||
);
|
||||
const textLength =
|
||||
current?.closest('[data-testid="assistant-message"]')?.textContent?.length ?? 0;
|
||||
result.textGrowth = Math.max(result.textGrowth, textLength - initialTextLength);
|
||||
if (current)
|
||||
result.maxDrift = Math.max(
|
||||
result.maxDrift,
|
||||
Math.abs(current.getBoundingClientRect().y - baseline),
|
||||
);
|
||||
const next = `${viewport.scrollHeight}:${viewport.scrollTop}:${current?.getBoundingClientRect().y}`;
|
||||
if (next !== signature) {
|
||||
signature = next;
|
||||
stableAt = now;
|
||||
}
|
||||
const complete =
|
||||
document.body.textContent?.includes("Worked for") &&
|
||||
document.body.textContent.includes("READER-STREAM-SETTLED") &&
|
||||
!document.querySelector('[data-testid="turn-working-indicator"]');
|
||||
if (complete && now - stableAt >= 750) {
|
||||
result.done = true;
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
return result;
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("scroll-to-bottom-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
stream.release();
|
||||
await page.waitForFunction((result) => result.done, recording);
|
||||
return { ...(await recording.jsonValue()), maxFollowGap };
|
||||
}
|
||||
|
||||
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
|
||||
export async function withVariedTimeline(
|
||||
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
|
||||
@@ -412,7 +297,11 @@ export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
|
||||
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
|
||||
const readingLine = 8 - availableScroll;
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
@@ -427,33 +316,25 @@ export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
// Wheel events can precede their scroll update, or include input already
|
||||
// applied in earlier frames. Locate the reader with this frame's scroll,
|
||||
// bounded by recent input. Growth below it can move the old anchor without
|
||||
// moving the reading line.
|
||||
const readerFollowsInput =
|
||||
intendedRow &&
|
||||
currentRows.has(intendedRow.id) &&
|
||||
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
|
||||
const enteredRowGrowth = readerFollowsInput
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + height - row.height;
|
||||
return growth + Math.max(0, height - row.height);
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
@@ -610,7 +491,7 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, "Show the delayed image");
|
||||
const image = page.getByRole("button", { name: "Reserved image", exact: true }).first();
|
||||
const image = page.getByRole("img", { name: "Reserved image" }).first();
|
||||
await expect(image).toBeVisible();
|
||||
const before = await image.boundingBox();
|
||||
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
|
||||
@@ -627,9 +508,8 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
|
||||
.toBe(240);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return image.evaluate((element: HTMLImageElement) =>
|
||||
Math.round((element.naturalWidth / element.naturalHeight) * 100),
|
||||
);
|
||||
const box = await image.boundingBox();
|
||||
return box ? Math.round((box.width / box.height) * 100) : 0;
|
||||
})
|
||||
.toBe(150);
|
||||
const after = await image.boundingBox();
|
||||
|
||||
@@ -135,14 +135,3 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
if (compact) {
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(text);
|
||||
return;
|
||||
}
|
||||
await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text);
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
|
||||
@@ -212,14 +212,6 @@ export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
/** Reach a window with the wheel even when other reports follow its card. */
|
||||
export async function scrollUsageToWindow(page: Page, label: string): Promise<void> {
|
||||
const target = await usageModal(page).getByText(label, { exact: true }).boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it.");
|
||||
await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2);
|
||||
}
|
||||
|
||||
/** The modal's own Close button. */
|
||||
export async function closeUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { connectSeedClient } from "./seed-client";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
@@ -31,35 +30,3 @@ export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function submitAgentChatAndExpectCwd(
|
||||
page: Page,
|
||||
workspace: { workspaceId: string; repoPath: string },
|
||||
): Promise<void> {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const message = "cwd draft create";
|
||||
await composerInput(page).fill(message);
|
||||
await composerInput(page).press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd }));
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-26
@@ -9,11 +9,7 @@ import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.BridgeReactContext
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ReactStylesDiffMap
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
@@ -51,29 +47,16 @@ class ScrollSelectionTest {
|
||||
assertNotEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
@Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
assertTrue(text.requestFocus())
|
||||
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||
assertEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
@Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||
assertNotEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) {
|
||||
private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
instrumentation.runOnMainSync {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = BridgeReactContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
scroll.scaleY = if (inverted) -1f else 1f
|
||||
val content = FrameLayout(context)
|
||||
val text = TextView(context).apply {
|
||||
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
|
||||
@@ -85,13 +68,6 @@ class ScrollSelectionTest {
|
||||
content.layout(0, 0, 300, 2000)
|
||||
scroll.measure(exact(300), exact(300))
|
||||
scroll.layout(0, 0, 300, 300)
|
||||
// Apply RN's actual Android inversion through the manager's transform decomposition.
|
||||
val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0))
|
||||
manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms)))
|
||||
if (inverted && transform == "scale") {
|
||||
assertEquals(1f, scroll.scaleY, 0f)
|
||||
assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f)
|
||||
}
|
||||
scroll.scrollTo(0, 400)
|
||||
check(scroll, text)
|
||||
}
|
||||
|
||||
+2
-9
@@ -1,6 +1,5 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
@@ -28,15 +27,9 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
}
|
||||
|
||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||
private val transformValues = FloatArray(9)
|
||||
|
||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||
// movement to user gestures and explicit scroll commands.
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
|
||||
if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -104,11 +104,7 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mdast-util-from-markdown": "2.0.3",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"micromark-extension-gfm-table": "^2.1.2",
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -118,14 +114,15 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -167,32 +164,6 @@
|
||||
"wrangler": "^4.105.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"enriched-markdown": {
|
||||
"enableMath": false,
|
||||
"enableCodeHighlight": true,
|
||||
"codeHighlightLanguages": [
|
||||
"json",
|
||||
"html",
|
||||
"css",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"go",
|
||||
"java",
|
||||
"javascript",
|
||||
"python",
|
||||
"c",
|
||||
"rust",
|
||||
"bash",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"cpp",
|
||||
"swift",
|
||||
"php",
|
||||
"ruby",
|
||||
"c-sharp"
|
||||
],
|
||||
"enableVideo": false
|
||||
},
|
||||
"reanimated": {
|
||||
"staticFeatureFlags": {
|
||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Regenerate from the repo root: node packages/app/scripts/generate-markdown-image-placeholder.mjs
|
||||
// sharp is already provided by the development dependency tree; it is not used at runtime.
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
|
||||
// Lucide Image (ISC), lucide-react-native/dist/esm/icons/image.mjs.
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
|
||||
<svg x="293.333333" y="293.333333" width="53.333333" height="53.333333" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="#808080" opacity="0.6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||||
<circle cx="9" cy="9" r="2"/>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>`;
|
||||
const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
|
||||
const uri = `data:image/png;base64,${png.toString("base64")}`;
|
||||
const sourceFile = new URL("../src/components/markdown/source.ts", import.meta.url);
|
||||
const source = await readFile(sourceFile, "utf8");
|
||||
await writeFile(
|
||||
sourceFile,
|
||||
source.replace(
|
||||
/export const PENDING_MARKDOWN_IMAGE =\s*"[^"]*";/,
|
||||
`export const PENDING_MARKDOWN_IMAGE =\n "${uri}";`,
|
||||
),
|
||||
);
|
||||
console.log(`Generated 640×640 Markdown image placeholder: ${png.length} bytes`);
|
||||
@@ -118,8 +118,8 @@ export function ChatFind({
|
||||
}, [active, isInteractive, model]);
|
||||
const close = useCallback(() => {
|
||||
model.close();
|
||||
viewportRef.current?.focus?.();
|
||||
}, [model, viewportRef]);
|
||||
root.current?.focus({ preventScroll: true });
|
||||
}, [model]);
|
||||
let status = "";
|
||||
if (state.phase === "searching") status = t("paneFind.searching");
|
||||
else if (state.phase === "loading") status = t("paneFind.loading");
|
||||
|
||||
@@ -65,60 +65,3 @@ it("matches whitespace across text nodes without inspecting hidden link destinat
|
||||
"world\nline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps semantic Markdown paragraphs separate while finding inline matches", () => {
|
||||
const row = content(
|
||||
"<p>İ😀hello <strong>world</strong>; hello <em>world</em>.</p><p>another paragraph</p>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "hello world").map((range) => range.toString())).toEqual([
|
||||
"hello world",
|
||||
"hello world",
|
||||
]);
|
||||
expect(findRenderedMatches(row, ".another")).toEqual([]);
|
||||
});
|
||||
|
||||
function pluginRow(html: string) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.pluginRow = "true";
|
||||
row.dataset.messageId = "message-1";
|
||||
row.innerHTML = html;
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
|
||||
const row = pluginRow(
|
||||
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
|
||||
"needle",
|
||||
"needle",
|
||||
]);
|
||||
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
|
||||
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves rows without the plugin marker limited to marked message text", () => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = "<div>needle</div>";
|
||||
document.body.append(row);
|
||||
expect(findRenderedMatches(row, "needle")).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts only drawn text in a plugin row, including an opened popover", () => {
|
||||
const row = pluginRow(
|
||||
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
|
||||
row.querySelector<HTMLElement>("[popover]")!.showPopover();
|
||||
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps text around nested plugin blocks in separate occurrences", () => {
|
||||
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
|
||||
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
|
||||
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
|
||||
.flatMap((tag) => [tag, `[data-paseo-markdown-tag="${tag}"]`])
|
||||
.map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
|
||||
.join(",");
|
||||
const IGNORED =
|
||||
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
|
||||
|
||||
/** Rendered rows carrying a source message identity, in reading order. */
|
||||
/** The rows of one message, in reading order. Each is one Markdown block. */
|
||||
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
@@ -21,55 +21,28 @@ export function findMessageMatches(
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
function renderedTextGroups(row: HTMLElement): Text[][] {
|
||||
const groups: Text[][] = [];
|
||||
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
|
||||
// whole row is searched and each div (a React Native View or Text) bounds a match.
|
||||
const pluginRow = row.dataset.pluginRow === "true";
|
||||
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
|
||||
const contents = pluginRow
|
||||
? [row]
|
||||
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
|
||||
for (const content of contents) {
|
||||
let group: { block: Element; texts: Text[] } | undefined;
|
||||
const walker = document.createTreeWalker(
|
||||
content,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node instanceof Element) {
|
||||
// Even an empty nested block separates the text before and after it.
|
||||
if (node.matches(blocks)) group = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
if (
|
||||
pluginRow &&
|
||||
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
)
|
||||
continue;
|
||||
const block = node.parentElement?.closest(blocks) ?? content;
|
||||
if (group?.block !== block) {
|
||||
group = { block, texts: [] };
|
||||
groups.push(group.texts);
|
||||
}
|
||||
group.texts.push(node);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
const groups = new Map<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
const block = node.parentElement?.closest(BLOCKS) ?? content;
|
||||
const texts = groups.get(block) ?? [];
|
||||
texts.push(node);
|
||||
groups.set(block, texts);
|
||||
}
|
||||
}
|
||||
const pattern = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
const ranges: Range[] = [];
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
for (const texts of groups.values()) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { ReactNode, RefObject } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
|
||||
export interface MountedMessageIds {
|
||||
has(messageId: string): boolean;
|
||||
}
|
||||
export interface ChatFindProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
@@ -12,7 +9,7 @@ export interface ChatFindProps {
|
||||
items: StreamItem[];
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
revealLoadedMessage(messageId: string): boolean;
|
||||
visibleMessageIds: MountedMessageIds;
|
||||
visibleMessageIds: ReadonlySet<string>;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface ChatFindExpansionProps {
|
||||
|
||||
@@ -98,23 +98,3 @@ function highlightedRow(): string | undefined {
|
||||
);
|
||||
return row?.dataset.historyRowId;
|
||||
}
|
||||
|
||||
it("waits for an asynchronous renderer before counting its matches", async () => {
|
||||
const root = transcript("message-1", ["needle"]);
|
||||
const content = root.querySelector('[data-message-text="true"]')!;
|
||||
content.setAttribute("aria-busy", "true");
|
||||
let settled = false;
|
||||
const reveal = viewport(root, {})
|
||||
.reveal("message-1", "needle", 0, new AbortController().signal)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
expect(settled).toBe(false);
|
||||
content.textContent = "needle and another needle";
|
||||
content.removeAttribute("aria-busy");
|
||||
expect(await reveal).toEqual({ occurrence: 0, count: 2 });
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@ export function createFindViewport({
|
||||
clear() {
|
||||
CSS.highlights.delete(highlightName);
|
||||
},
|
||||
// The source message owns navigation; the rendered occurrence owns its position.
|
||||
// A message renders as one row per Markdown block, so an occurrence can sit in
|
||||
// any of them and the chosen one decides where the viewport lands.
|
||||
reveal(messageId, query, occurrence, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let frame = 0;
|
||||
@@ -60,10 +61,6 @@ export function createFindViewport({
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
if (rows.some((row) => row.querySelector('[aria-busy="true"]'))) {
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
const ranges = findMessageMatches(getRoot(), messageId, query);
|
||||
const index =
|
||||
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("useChatOutline", () => {
|
||||
timelineEpoch,
|
||||
enabled,
|
||||
viewportRef,
|
||||
mountedHistoryMessageIds: new Set(),
|
||||
visibleMessageIds: new Set(),
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{
|
||||
@@ -278,7 +278,7 @@ describe("useChatOutline", () => {
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ mountedHistoryMessageIds }) =>
|
||||
({ visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -288,10 +288,10 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
mountedHistoryMessageIds,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{ initialProps: { mountedHistoryMessageIds: new Set<string>() } },
|
||||
{ initialProps: { visibleMessageIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
@@ -299,7 +299,7 @@ describe("useChatOutline", () => {
|
||||
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ mountedHistoryMessageIds: new Set(["older-prompt"]) });
|
||||
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
@@ -327,7 +327,7 @@ describe("useChatOutline", () => {
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, mountedHistoryMessageIds }) =>
|
||||
({ tail, visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -337,13 +337,13 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
mountedHistoryMessageIds,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
visibleMessageIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set<string>() });
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set([fetchedPrompt.id]) });
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
@@ -392,7 +392,7 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
visibleMessageIds: new Set([livePrompt.id]),
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -33,8 +33,7 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
// Message ids of the history the viewport has mounted. Live head messages are always mounted.
|
||||
mountedHistoryMessageIds?: ReadonlySet<string>;
|
||||
visibleMessageIds?: ReadonlySet<string>;
|
||||
revealLoadedMessage?: (messageId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -45,24 +44,6 @@ export interface ChatOutline {
|
||||
reportReadingPosition: (seq: number | null) => void;
|
||||
}
|
||||
|
||||
function latestPromptSeqIn(items: StreamItem[], timelineEpoch: string | null): number {
|
||||
let latest = -1;
|
||||
for (const item of items) {
|
||||
if (item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch) {
|
||||
latest = Math.max(latest, item.timelineCursor.seq);
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function findLoadedItem(
|
||||
tail: StreamItem[],
|
||||
head: StreamItem[],
|
||||
predicate: (item: StreamItem) => boolean,
|
||||
): StreamItem | undefined {
|
||||
return tail.find(predicate) ?? head.find(predicate);
|
||||
}
|
||||
|
||||
export function useChatOutline({
|
||||
agentId,
|
||||
serverId,
|
||||
@@ -72,7 +53,7 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
mountedHistoryMessageIds,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
@@ -81,19 +62,18 @@ export function useChatOutline({
|
||||
const readingSeqRef = useRef<number | null>(null);
|
||||
const nextJumpRequestIdRef = useRef(0);
|
||||
const nextIndexRequestIdRef = useRef(0);
|
||||
// A disabled outline (every native viewport) must not pay for the timeline per delta.
|
||||
const loadedTail = enabled ? tail : NO_STREAM_ITEMS;
|
||||
const loadedHead = enabled ? (head ?? NO_STREAM_ITEMS) : NO_STREAM_ITEMS;
|
||||
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
|
||||
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
|
||||
|
||||
// The viewed timeline already owns live delivery and reconnect catch-up. Its complete
|
||||
// loaded items (including rows outside the mounted window) invalidate the prompt index.
|
||||
// Only the head changes per streamed delta, so the tail's contribution is memoized.
|
||||
const tailPromptSeq = useMemo(
|
||||
() => latestPromptSeqIn(loadedTail, timelineEpoch),
|
||||
[loadedTail, timelineEpoch],
|
||||
const latestPromptSeq = loadedItems.reduce(
|
||||
(latest, item) =>
|
||||
item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch
|
||||
? Math.max(latest, item.timelineCursor.seq)
|
||||
: latest,
|
||||
-1,
|
||||
);
|
||||
const latestPromptSeq = Math.max(tailPromptSeq, latestPromptSeqIn(loadedHead, timelineEpoch));
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
@@ -155,18 +135,10 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
const target = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === pendingJump.seq,
|
||||
);
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
const isMounted =
|
||||
mountedHistoryMessageIds === undefined ||
|
||||
mountedHistoryMessageIds.has(target.id) ||
|
||||
loadedHead.includes(target);
|
||||
if (!isMounted) {
|
||||
if (visibleMessageIds?.has(target.id) === false) {
|
||||
revealLoadedMessage?.(target.id);
|
||||
return;
|
||||
}
|
||||
@@ -178,48 +150,40 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [
|
||||
loadedHead,
|
||||
loadedTail,
|
||||
mountedHistoryMessageIds,
|
||||
pendingJump,
|
||||
revealLoadedMessage,
|
||||
viewportRef,
|
||||
]);
|
||||
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
|
||||
|
||||
const jumpToPrompt = useStableEvent((seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === seq,
|
||||
);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
|
||||
);
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
}
|
||||
|
||||
@@ -49,6 +49,17 @@ function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_me
|
||||
};
|
||||
}
|
||||
|
||||
function assistantBlock(
|
||||
id: string,
|
||||
blockIndex: number,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
...assistantMessage(id),
|
||||
blockGroupId: "response",
|
||||
blockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
return new Map([
|
||||
[
|
||||
@@ -98,4 +109,30 @@ describe("turn footer spacing", () => {
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["web", false],
|
||||
["web", true],
|
||||
["native", false],
|
||||
["native", true],
|
||||
] as const)(
|
||||
"compacts both edges of the final split paragraph on %s when active=%s",
|
||||
(platform, isTurnActive) => {
|
||||
const strategy = strategyFor(platform);
|
||||
const first = assistantBlock("first", 0);
|
||||
const last = assistantBlock("last", 1);
|
||||
const layout = layoutStream({
|
||||
strategy,
|
||||
isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), first]),
|
||||
liveHead: strategy.orderHead([last]),
|
||||
timingByAssistantId: timingFor(last.id),
|
||||
});
|
||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||
|
||||
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
|
||||
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "use
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
_block?: { groupId: string; index: number },
|
||||
block?: { groupId: string; index: number },
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
@@ -33,6 +33,7 @@ function assistantMessage(
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -317,7 +318,7 @@ describe("layoutStream", () => {
|
||||
expect(assistantRow.frameOrder).toBe("content-then-footer");
|
||||
});
|
||||
|
||||
it("keeps separate messages spaced across history and the live head", () => {
|
||||
it("compacts assistant block spacing across the history and live-head boundary", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
@@ -327,8 +328,8 @@ describe("layoutStream", () => {
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("default");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
|
||||
|
||||
@@ -311,8 +311,10 @@ function layoutSegmentItem(
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantMessageSpacing({
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
function revealHistory(tail: StreamItem[]): WeakRef<StreamItem[]>[] {
|
||||
const windows: WeakRef<StreamItem[]>[] = [];
|
||||
for (let start = tail.length - 20; start >= 20; start -= 20) {
|
||||
const model = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head: [],
|
||||
historyStart: start,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
});
|
||||
windows.push(new WeakRef(model.history));
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
it("releases obsolete history windows and their source timeline", async () => {
|
||||
setFlagsFromString("--expose_gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
setFlagsFromString("--no-expose_gc");
|
||||
async function collect() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
gc();
|
||||
}
|
||||
}
|
||||
let tail: StreamItem[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||
kind: i % 2 ? "assistant_message" : "user_message",
|
||||
id: `row-${i}`,
|
||||
text: "text",
|
||||
timestamp: new Date(1700000000000 + i * 1000),
|
||||
}));
|
||||
const windows = revealHistory(tail);
|
||||
await collect();
|
||||
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
|
||||
|
||||
windows.push(...revealHistory(tail));
|
||||
await collect();
|
||||
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
|
||||
|
||||
tail = [];
|
||||
await collect();
|
||||
expect(windows.every((ref) => ref.deref() === undefined)).toBe(true);
|
||||
});
|
||||
@@ -240,47 +240,4 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
"reuses rendered history and timing across live-head updates inside a history window on %s",
|
||||
(platform) => {
|
||||
const at = (seed: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seed));
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
tail.push(
|
||||
{ ...userMessage(`u${index}`, 0), timestamp: at(index * 2) },
|
||||
{ ...assistantMessage(`a${index}`, 0), timestamp: at(index * 2 + 1) },
|
||||
);
|
||||
}
|
||||
const live = (text: string): StreamItem => ({
|
||||
kind: "assistant_message",
|
||||
id: "live",
|
||||
text,
|
||||
timestamp: at(81),
|
||||
});
|
||||
const build = (head: StreamItem[]) =>
|
||||
buildAgentStreamRenderModel({
|
||||
isTurnActive: true,
|
||||
activeTurnStartedAt: tail.at(-2)?.timestamp ?? null,
|
||||
tail,
|
||||
head,
|
||||
platform,
|
||||
isMobileBreakpoint: false,
|
||||
historyStart: 60,
|
||||
});
|
||||
|
||||
const first = build([live("live")]);
|
||||
const second = build([live("live grows")]);
|
||||
|
||||
expect(second.history).toBe(first.history);
|
||||
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
||||
expect(first.history).toHaveLength(20);
|
||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
||||
completedAt: tail[77]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,29 +53,11 @@ const splitHistoryCache = new WeakMap<
|
||||
StreamItem[],
|
||||
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
|
||||
>();
|
||||
// A streamed delta rebuilds the model with the same tail. Everything below is keyed on
|
||||
// the rendered tail's identity, so the slice has to be reused, not recomputed.
|
||||
const renderedTailCache = new WeakMap<
|
||||
const turnTimingCache = new WeakMap<
|
||||
StreamItem[],
|
||||
{ historyStart: number; rendered: StreamItem[] }
|
||||
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
|
||||
>();
|
||||
|
||||
function getRenderedTail(tail: StreamItem[], historyStart: number | undefined): StreamItem[] {
|
||||
if (!historyStart) {
|
||||
renderedTailCache.delete(tail);
|
||||
return tail;
|
||||
}
|
||||
const cached = renderedTailCache.get(tail);
|
||||
if (cached?.historyStart === historyStart) {
|
||||
return cached.rendered;
|
||||
}
|
||||
const rendered = tail.slice(historyStart);
|
||||
// Revealing older history replaces the previous window, allowing its arrays and
|
||||
// downstream layouts to be collected while the unchanged source tail stays alive.
|
||||
renderedTailCache.set(tail, { historyStart, rendered });
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function getOrderedItems(params: {
|
||||
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
|
||||
source: StreamItem[];
|
||||
@@ -147,6 +129,32 @@ function splitOrderedTail(params: {
|
||||
return split;
|
||||
}
|
||||
|
||||
function getTurnTiming(params: {
|
||||
isTurnActive: boolean;
|
||||
activeTurnStartedAt: Date | null;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
let cachedByHead = turnTimingCache.get(params.tail);
|
||||
if (!cachedByHead) {
|
||||
cachedByHead = new WeakMap();
|
||||
turnTimingCache.set(params.tail, cachedByHead);
|
||||
}
|
||||
let cachedByActivity = cachedByHead.get(params.head);
|
||||
if (!cachedByActivity) {
|
||||
cachedByActivity = new Map();
|
||||
cachedByHead.set(params.head, cachedByActivity);
|
||||
}
|
||||
const activityKey = `${params.isTurnActive}:${params.activeTurnStartedAt?.getTime() ?? "none"}`;
|
||||
const cached = cachedByActivity.get(activityKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const timing = deriveStreamTurnTiming(params);
|
||||
cachedByActivity.set(activityKey, timing);
|
||||
return timing;
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput,
|
||||
): AgentStreamRenderModel {
|
||||
@@ -155,7 +163,7 @@ export function buildAgentStreamRenderModel(
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
|
||||
const renderedTail = getRenderedTail(input.tail, input.historyStart);
|
||||
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
|
||||
const orderedTail = getOrderedItems({
|
||||
cache: orderedTailCache,
|
||||
source: renderedTail,
|
||||
@@ -181,7 +189,7 @@ export function buildAgentStreamRenderModel(
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const turnTiming = deriveStreamTurnTiming({
|
||||
const turnTiming = getTurnTiming({
|
||||
isTurnActive: input.isTurnActive,
|
||||
activeTurnStartedAt: input.activeTurnStartedAt,
|
||||
tail: renderedTail,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
|
||||
describe("native scroll-to-bottom animation", () => {
|
||||
it("jumps across unmounted history instead of animating through blank rows", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps near-bottom movement eligible for animation", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds travel by the bottom-side overscan, not the whole window", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not animate before the viewport has been measured", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export function shouldAnimateNativeScrollToBottom(input: {
|
||||
offsetY: number;
|
||||
viewportHeight: number;
|
||||
windowSize: number;
|
||||
}): boolean {
|
||||
// VirtualizedList splits the extra window around the viewport. An animated
|
||||
// jump farther than the bottom-side overscan crosses unmounted history.
|
||||
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
|
||||
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
@@ -8,11 +10,9 @@ import {
|
||||
type ToolCallItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -123,25 +123,6 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
|
||||
return [...result.tail, ...result.head];
|
||||
}
|
||||
|
||||
describe("whole-message Markdown presentation", () => {
|
||||
it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
|
||||
const present = createStreamPresentation();
|
||||
const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
for (const length of [10, 30, text.length]) {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = present({
|
||||
...presentationOptions,
|
||||
tail: [],
|
||||
head: source,
|
||||
transform: undefined,
|
||||
});
|
||||
expect(rows(result)).toEqual(source);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream presentation through installed plugins", () => {
|
||||
it("offers every source tool call to an installed transformer in Overview mode", () => {
|
||||
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
|
||||
@@ -217,17 +198,26 @@ describe("stream presentation through installed plugins", () => {
|
||||
{ text: "Header\n\nBody", phase: "streaming" },
|
||||
]);
|
||||
expect(rows(harness.render(transform))).toHaveLength(1);
|
||||
expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
|
||||
expect(rows(harness.render()).map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"assistant_message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the complete message when streaming finishes", () => {
|
||||
it("keeps completed native blocks stable while the last block grows and finishes", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Intro\n\n"));
|
||||
const last = harness.send(assistant("\n\nAfter\n- item"));
|
||||
const first = harness.send(assistant("Intro\n\n"));
|
||||
const growing = harness.send(assistant("\n\nAfter\n"));
|
||||
const last = harness.send(assistant("- item"));
|
||||
const completed = harness.send({ type: "turn_completed", provider: "claude" });
|
||||
expect(last.tail).toEqual([]);
|
||||
expect(last.head).toMatchObject([{ text: "Intro\n\n\n\nAfter\n- item" }]);
|
||||
expect(completed.tail).toEqual(last.head);
|
||||
expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
|
||||
expect(first.head).toMatchObject([{ text: "", blockIndex: 1 }]);
|
||||
expect(growing.tail[0]).toBe(first.tail[0]);
|
||||
expect(growing.tail[1]).toBe(first.head[0]);
|
||||
expect(last.tail).toBe(growing.tail);
|
||||
expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
|
||||
expect(completed.tail).toEqual(rows(last));
|
||||
expect(completed.tail[2]).toBe(last.head[0]);
|
||||
expect(completed.head).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -235,8 +225,8 @@ describe("stream presentation through installed plugins", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
|
||||
const result = harness.send(assistant("\n\nconst b = 2;"));
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
expect(result.tail).toMatchObject([{ text: "Intro" }]);
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
@@ -254,13 +244,14 @@ describe("stream presentation through installed plugins", () => {
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
// never leaves an empty row behind and the reference still resolves.
|
||||
it("preserves source identity through history and the live head", () => {
|
||||
it("splits an assistant message into the same blocks through history and through the live head", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const present = (placement: "tail" | "head") =>
|
||||
rows(
|
||||
createStreamPresentation()({
|
||||
@@ -270,7 +261,20 @@ describe("stream presentation through installed plugins", () => {
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(present("tail")).toEqual(source);
|
||||
expect(present("tail")).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "[Link][docs]\n\n[docs]: https://example.com",
|
||||
},
|
||||
{
|
||||
id: `${messageId}:block:1`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 1,
|
||||
text: "Closing paragraph.",
|
||||
},
|
||||
]);
|
||||
const blocks = (items: StreamItem[]) =>
|
||||
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
|
||||
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
|
||||
@@ -278,7 +282,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
|
||||
// Rows are addressed by id from outside presentation, so the same text has to land on
|
||||
// the same ids whether the reader watched it arrive or opened the chat afterwards.
|
||||
it("gives a streamed message the same id as its hydrated text", () => {
|
||||
it("gives a streamed message the block ids its hydrated text would have had", () => {
|
||||
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
|
||||
const harness = streamHarness();
|
||||
for (let end = 8; end < text.length; end += 8) {
|
||||
@@ -300,7 +304,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical source that replaced the streamed message", () => {
|
||||
it("repoints a block at the canonical source that replaced the streamed one", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Streamed answer"));
|
||||
const streamed = rows(harness.render());
|
||||
@@ -334,8 +338,15 @@ describe("stream presentation through installed plugins", () => {
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toEqual(source);
|
||||
expect(result.tail[0]!.id).toBe(messageId);
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "Only one paragraph.",
|
||||
},
|
||||
]);
|
||||
expect(result.tail[0]!.id).not.toBe(messageId);
|
||||
});
|
||||
|
||||
it("continues to stream inline reasoning with a stable plugin row", () => {
|
||||
@@ -552,7 +563,13 @@ describe("timeline presentation", () => {
|
||||
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
|
||||
},
|
||||
];
|
||||
expect(projectTimelineItems(items)).toEqual(items);
|
||||
expect(projectTimelineItems(items)).toEqual(
|
||||
items.map((item) =>
|
||||
item.kind === "assistant_message"
|
||||
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
expect(projectTimelineItems(items)[0]).toBe(items[0]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import {
|
||||
prepareToolCallHistory,
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
@@ -16,13 +17,42 @@ interface PresentationInput {
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
|
||||
return previous.length === next.length && previous.every((item, index) => item === next[index])
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source message a display row belongs to. Every assistant message is split into
|
||||
* Markdown blocks, so an assistant row id never equals its message id; anything that
|
||||
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
|
||||
* plugin row a timeline transformer projected answers for the item it replaced, whose
|
||||
* id is what the chat outline and find hold.
|
||||
*/
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
/**
|
||||
* A block is reusable only when it still stands for the same source state. Text and
|
||||
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
|
||||
* through the row — a reused block carrying a pre-canonical cursor reports a stale
|
||||
* reading position and weakens fork-boundary resolution.
|
||||
*/
|
||||
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
|
||||
return (
|
||||
block.id === id &&
|
||||
block.turnId === source.turnId &&
|
||||
block.timestamp.getTime() === source.timestamp.getTime() &&
|
||||
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
|
||||
block.timelineCursor?.seq === source.timelineCursor?.seq
|
||||
);
|
||||
}
|
||||
|
||||
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
|
||||
@@ -41,39 +71,115 @@ export function createStreamPresentation() {
|
||||
return projected;
|
||||
}
|
||||
|
||||
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
let liveSources = new Map<string, AssistantMessageItem>();
|
||||
let historySource: StreamItem[] | undefined;
|
||||
let historyTransform: TimelineItemTransform | undefined;
|
||||
let historyRows: StreamItem[] = [];
|
||||
let displayTail: StreamItem[] = [];
|
||||
let displayHistory: StreamItem[] | undefined;
|
||||
let promotedRows: StreamItem[] = [];
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
/**
|
||||
* One display row per Markdown block. Each block renders on its own, so a construct
|
||||
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
|
||||
* link reference definitions with the block that uses them, but a reference pointing
|
||||
* at a definition several blocks away renders as literal text. Streamed messages
|
||||
* always behaved this way; history now matches them.
|
||||
*/
|
||||
function nativeBlocks(item: StreamItem): StreamItem[] {
|
||||
if (item.kind === "user_message") return [presentUserMessage(item)];
|
||||
if (item.kind !== "assistant_message") return [item];
|
||||
const cached = blocksBySource.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
const previous = previousSource && blocksBySource.get(previousSource);
|
||||
// Parse only the growing last block on append, as the old live reducer did.
|
||||
// Canonical text replacements are parsed afresh instead of joining fragments.
|
||||
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
|
||||
let prefix: AssistantMessageItem[] = [];
|
||||
let growingText = item.text;
|
||||
if (isAppend) {
|
||||
prefix = previous.slice(0, -1);
|
||||
growingText =
|
||||
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
|
||||
}
|
||||
const parsed = splitMarkdownBlocks(growingText);
|
||||
// Whitespace-only text has no block, and a message still owns exactly one row.
|
||||
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
|
||||
|
||||
const blocks = [...prefix];
|
||||
for (const [offset, text] of textBlocks.entries()) {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
|
||||
blocks.push(existing);
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
...item,
|
||||
id,
|
||||
blockGroupId: item.id,
|
||||
blockIndex: index,
|
||||
text: blockText,
|
||||
});
|
||||
}
|
||||
blocksBySource.set(item, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
// One rendering path: history splits the same way the live head does, and
|
||||
// `blocksBySource` keeps both the live block identities and the split cost
|
||||
// from being paid again when only the tail's array identity changed.
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
(item) => presentRows(item),
|
||||
nativeBlocks,
|
||||
);
|
||||
historySource = input.tail;
|
||||
historyTransform = input.transform;
|
||||
}
|
||||
const head: StreamItem[] = [];
|
||||
const promoted: StreamItem[] = [];
|
||||
const nextLiveSources = new Map<string, AssistantMessageItem>();
|
||||
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
const blocks = nativeBlocks(item);
|
||||
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
|
||||
if (item.kind === "assistant_message" && blocks.length > 1) {
|
||||
promoted.push(...blocks.slice(0, -1));
|
||||
head.push(blocks[blocks.length - 1]!);
|
||||
} else {
|
||||
head.push(...blocks);
|
||||
}
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
liveSources = nextLiveSources;
|
||||
const nextPromoted = retainItems(promotedRows, promoted);
|
||||
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
|
||||
displayTail = historyRows;
|
||||
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
|
||||
displayHistory = historyRows;
|
||||
promotedRows = nextPromoted;
|
||||
}
|
||||
if (preparedTail !== displayTail || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, displayTail);
|
||||
preparedTail = displayTail;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
tail: displayTail,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
|
||||
@@ -1,20 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
const message: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "m",
|
||||
text: "First\n\nSecond",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
describe("whole-message spacing", () => {
|
||||
it("compacts only the gap to its turn footer", () => {
|
||||
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
|
||||
expect(getAssistantMessageSpacing({ item: message, hasFooterBelow: true })).toBe(
|
||||
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
|
||||
|
||||
function assistantBlock(params: {
|
||||
id: string;
|
||||
blockGroupId: string;
|
||||
blockIndex: number;
|
||||
text?: string;
|
||||
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id: params.id,
|
||||
blockGroupId: params.blockGroupId,
|
||||
blockIndex: params.blockIndex,
|
||||
text: params.text ?? "",
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "bash",
|
||||
arguments: "cmd",
|
||||
result: null,
|
||||
status: "executing",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isSameAssistantBlockGroup", () => {
|
||||
it("returns true for two assistant blocks with the same blockGroupId", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for blocks from different groups", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when one item is not an assistant_message", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null neighbors", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantBlockSpacing", () => {
|
||||
it("returns default for non-assistant items", () => {
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default when no same-group neighbors exist", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||
});
|
||||
|
||||
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(
|
||||
getAssistantBlockSpacing({
|
||||
item: a,
|
||||
aboveItem: null,
|
||||
belowItem: null,
|
||||
hasFooterBelow: true,
|
||||
}),
|
||||
).toBe("compactBottom");
|
||||
});
|
||||
|
||||
it("returns compactTop when the item above is in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
|
||||
"compactTop",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compactBottom when the item below is in the same block group", () => {
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
|
||||
"compactBottom",
|
||||
);
|
||||
});
|
||||
it("keeps separate messages separated", () => {
|
||||
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
|
||||
|
||||
it("returns compactBoth when both neighbors are in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
|
||||
"compactBoth",
|
||||
);
|
||||
});
|
||||
|
||||
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
|
||||
const tailBlock = assistantBlock({
|
||||
id: "group-1:block:0",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 0,
|
||||
text: "First paragraph",
|
||||
});
|
||||
const headBlock = assistantBlock({
|
||||
id: "group-1:head",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 1,
|
||||
text: "Second paragraph",
|
||||
});
|
||||
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
|
||||
).toBe("compactBottom");
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
|
||||
).toBe("compactTop");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
export function getAssistantMessageSpacing(params: {
|
||||
export function isSameAssistantBlockGroup(params: {
|
||||
item: StreamItem | null | undefined;
|
||||
other: StreamItem | null | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
params.item?.kind === "assistant_message" &&
|
||||
params.other?.kind === "assistant_message" &&
|
||||
params.item.blockGroupId !== undefined &&
|
||||
params.item.blockGroupId === params.other.blockGroupId
|
||||
);
|
||||
}
|
||||
|
||||
export function getAssistantBlockSpacing(params: {
|
||||
item: StreamItem;
|
||||
aboveItem: StreamItem | null | undefined;
|
||||
belowItem: StreamItem | null | undefined;
|
||||
hasFooterBelow?: boolean;
|
||||
}): "default" | "compactBottom" {
|
||||
return params.item.kind === "assistant_message" && params.hasFooterBelow
|
||||
? "compactBottom"
|
||||
: "default";
|
||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||
if (params.item.kind !== "assistant_message") {
|
||||
return "default";
|
||||
}
|
||||
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||
const compactBottom =
|
||||
params.hasFooterBelow ||
|
||||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||
if (compactTop && compactBottom) return "compactBoth";
|
||||
if (compactTop) return "compactTop";
|
||||
if (compactBottom) return "compactBottom";
|
||||
return "default";
|
||||
}
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
@@ -40,5 +62,8 @@ export function getGapBetweenStreamItems(
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
||||
return SPACING[3];
|
||||
}
|
||||
return SPACING[4];
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardStreamInset } from "@/keyboard/shift";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -65,7 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -240,13 +238,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -599,11 +591,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user