mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1544372dbd | ||
|
|
a5c566f807 | ||
|
|
9521ee9955 | ||
|
|
bd5061e7bd | ||
|
|
df7f1f0b75 | ||
|
|
cf62b00911 | ||
|
|
efd6d95f91 | ||
|
|
1a6a9fa8d0 | ||
|
|
a7a21d91c2 | ||
|
|
b2da58b5bf | ||
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 | ||
|
|
c517d07d6f | ||
|
|
6388c466b1 | ||
|
|
3c48ac3ad2 |
+158
-95
@@ -8,9 +8,13 @@ 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: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -54,10 +58,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
|
||||
|
||||
format:
|
||||
name: format
|
||||
quality:
|
||||
name: quality
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
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') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -68,36 +72,63 @@ 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
|
||||
@@ -143,43 +174,56 @@ 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_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
- &server_fetch
|
||||
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
|
||||
|
||||
- name: Install dependencies
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
- &server_agent_clis
|
||||
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
|
||||
|
||||
- name: Build server dependencies
|
||||
- &server_build
|
||||
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:
|
||||
env: &server_test_env
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
- &server_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
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
- &server_hub_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
|
||||
|
||||
@@ -188,7 +232,41 @@ 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_test_steps
|
||||
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
|
||||
|
||||
# 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 —
|
||||
@@ -332,52 +410,19 @@ 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/4)
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
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
|
||||
@@ -405,9 +450,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Balance contiguous file groups using CI durations and the trimmed
|
||||
# inventory. Required check names and worker counts stay unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "187:183:184:201"
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -420,56 +465,74 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/4)
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/4)
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/4)
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
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
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
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: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
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
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
+16
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.1 - 2026-10-16
|
||||
## 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.
|
||||
|
||||
@@ -27,8 +27,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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
|
||||
@@ -37,6 +40,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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
|
||||
|
||||
@@ -51,6 +55,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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))
|
||||
@@ -59,7 +64,12 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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))
|
||||
@@ -85,9 +95,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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))
|
||||
@@ -108,6 +122,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- 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))
|
||||
|
||||
@@ -18,7 +18,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -154,8 +154,6 @@ npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
- Use one browser test per causal user journey, with named `test.step()` blocks for behaviors reached along the way. Reuse the open form, workspace, and browser state. Start another test for a distinct initial state, failure injection, race ordering, viewport, or destructive branch.
|
||||
- Keep pure test-helper checks in `packages/app/e2e/support/helpers/*.test.ts`; the app unit job runs them without Metro, a daemon, or a browser.
|
||||
|
||||
### File naming
|
||||
|
||||
|
||||
@@ -251,7 +251,8 @@ 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/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
|
||||
labels for every Paseo MCP tool. 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-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36349,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37469,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37721,10 +37721,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37979,7 +37979,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38904,14 +38904,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38921,8 +38921,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38944,7 +38944,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.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.1",
|
||||
"@getpaseo/highlight": "0.12.0-beta.1",
|
||||
"@getpaseo/plugin": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@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",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40119,7 +40119,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41727,7 +41727,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
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
|
||||
@@ -460,7 +460,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "e2e-fast-stream",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
@@ -549,7 +549,7 @@ async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
|
||||
title: "Submission missed running transition",
|
||||
model: "e2e-fast-stream",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
@@ -700,7 +700,7 @@ async function expectCanonicalOrderWinsAcrossOverlappingClients(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
|
||||
title: "Cross-client submission order",
|
||||
model: "e2e-fast-stream",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const localPrompt = "Send this after the other client turn.";
|
||||
const remotePrompt = "Commit this other client turn first.";
|
||||
|
||||
@@ -36,25 +36,15 @@ test.describe("Host agent skills", () => {
|
||||
page,
|
||||
skills,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const custom = skills.available.slice(0, 2);
|
||||
const cancelledAddition = skills.available[2]!;
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await test.step("the desktop sheet gives the bundled label its intended inset", async () => {
|
||||
await expectBundledSkillsInset(page);
|
||||
});
|
||||
await chooseCustomSkills(page, custom);
|
||||
await saveSkillSelection(page);
|
||||
await expectInstalledSkills(skills, custom);
|
||||
await expectSavedSelection(skills, { mode: "custom", skills: custom });
|
||||
await test.step("installation ignores agent config environment overrides", async () => {
|
||||
await expect(access(`${skills.home}/../ignored-claude-config/skills`)).rejects.toThrow(
|
||||
"ENOENT",
|
||||
);
|
||||
await expect(access(`${skills.home}/../ignored-codex-home/skills`)).rejects.toThrow("ENOENT");
|
||||
});
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, cancelledAddition);
|
||||
@@ -160,6 +150,23 @@ test.describe("Host agent skills", () => {
|
||||
await expectInstalledSkills(skills, secondSelection.skills);
|
||||
await expectSavedSelection(skills, secondSelection);
|
||||
});
|
||||
|
||||
test("ignores agent config environment overrides", async ({ skills }) => {
|
||||
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
|
||||
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-claude-config/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-codex-home/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
@@ -185,14 +192,24 @@ test.describe("Agent skills sheet inset", () => {
|
||||
await openSkillSelection(page);
|
||||
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
|
||||
|
||||
await expectBundledSkillsInset(page);
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("desktop", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
await openSkillSelection(page);
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectBundledSkillsInset(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect } from "@playwright/test";
|
||||
import {
|
||||
findScrollJumps,
|
||||
expectImageSpaceReserved,
|
||||
type ScrollFrame,
|
||||
observeTimelinePages,
|
||||
openOnlyTimelineTail,
|
||||
recordUpwardTraversal,
|
||||
@@ -12,6 +14,179 @@ import {
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).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,
|
||||
|
||||
@@ -51,9 +51,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes open tabs and keeps the session archived when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -67,51 +65,28 @@ test.describe("Archive tab reconciliation", () => {
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await test.step("open both sessions in two pages", async () => {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
});
|
||||
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
|
||||
await test.step("prune the archived tab across both pages and reload", async () => {
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
|
||||
await test.step("reopen the archived session without unarchiving it", async () => {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
@@ -148,4 +123,35 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await expectLiveAssistantText(page, visibleAfterFork);
|
||||
});
|
||||
|
||||
test("focuses an existing-workspace fork draft and keeps its attachment after submission", async ({
|
||||
test("focuses a forked assistant turn in a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
@@ -101,44 +101,55 @@ test.describe("Assistant fork menu", () => {
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await test.step("complete a second assistant turn in the visible source tab", async () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
|
||||
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-submit-",
|
||||
title: "Assistant fork tab submit",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await test.step("fork the latest turn into the selected draft tab", async () => {
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await expectChatHistoryAttachment(page);
|
||||
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
await submitMessage(page, "");
|
||||
|
||||
await test.step("submit that draft with its chat-history attachment", async () => {
|
||||
await submitMessage(page, "");
|
||||
|
||||
const userMessage = page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
|
||||
@@ -262,54 +262,15 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clean Changes opens committed changes and navigates an empty comparison", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
await test.step("Changes opens the populated committed comparison for a clean checkout", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("an empty Changes comparison links to the populated comparison", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("Jump to file stays out of the desktop diff and of an empty comparison", async () => {
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
@@ -336,6 +297,29 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -351,109 +335,223 @@ test("Changes comparison controls the working diff and tree selection focuses it
|
||||
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
|
||||
});
|
||||
|
||||
test("horizontal diff scrolling preserves its header and offset across collapse", async ({
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("canvas file headers select without toggling for context menu and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("every interactive file header has the same hover feedback", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await test.step("horizontal body scrolling never moves or repaints the canvas header", async () => {
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
await test.step("collapsing and expanding restores the real horizontal scroll offset", async () => {
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect.poll(() => readHorizontalDiffOffset(page)).toBe(retainedOffset);
|
||||
});
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("file headers scroll with the document and hand off without gaps", async ({
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
await test.step("in-flow file headers move with the diff document", async () => {
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
await test.step("the outgoing sticky header hands off without a gap or overlap", async () => {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const headerAt = (y: number) =>
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return [14, 16].map(
|
||||
(y) =>
|
||||
document
|
||||
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null;
|
||||
return [headerAt(14), headerAt(16)];
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||
);
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
test("browse the ordered changes tree and recursively collapse its folders", async ({ page }) => {
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes tree aligns every file status after its diff stat", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the scrolling diff lists files in changes tree order", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeNestedFolders: true,
|
||||
includeRootFileSortingFirst: true,
|
||||
@@ -461,70 +559,72 @@ test("browse the ordered changes tree and recursively collapse its folders", asy
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await test.step("the scrolling diff lists files in changes tree order", async () => {
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("changes context menu recursively collapses descendant folders", async () => {
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
@@ -661,25 +761,70 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("inspect, review, open, and duplicate changed files in the workspace", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await test.step("workspace controls update the comparison and presentation", async () => {
|
||||
await inspectChangesWorkspaceControls(page, workspace);
|
||||
});
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await scrollDiffToTop(page);
|
||||
await reviewChangedLine(page);
|
||||
await scrollDiffToTop(page);
|
||||
await inspectChangedFileHeaders(page);
|
||||
await test.step("duplicate the file and its folder from Changes", async () => {
|
||||
await page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).click();
|
||||
await duplicateChangedFiles(page, workspace);
|
||||
});
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
@@ -745,6 +890,18 @@ test("compact Changes jumps to a file from the changed-files sheet", async ({ pa
|
||||
});
|
||||
});
|
||||
|
||||
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
|
||||
const committed = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, committed, 90_000);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, committed);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -856,6 +1013,33 @@ test("changes diff waits for configured fonts, then applies code typography chan
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -874,6 +1058,21 @@ test("split canvas creates a review on the changed side and keeps it in that col
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
|
||||
test("scrolling clears the hovered review affordance", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -921,6 +1120,33 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1071,6 +1297,23 @@ test("dragging within one wide grapheme or outside its cell never opens a review
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400));
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft),
|
||||
)
|
||||
.toBe(retainedOffset);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
@@ -1975,242 +2218,3 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
});
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
async function readHorizontalDiffOffset(page: Page): Promise<number> {
|
||||
return page
|
||||
.getByTestId("diff-file-0-horizontal-scroll")
|
||||
.evaluate((element) => element.scrollLeft);
|
||||
}
|
||||
|
||||
async function inspectChangesWorkspaceControls(
|
||||
page: Page,
|
||||
workspace: DirtyWorkspace,
|
||||
): Promise<void> {
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
}
|
||||
|
||||
async function reviewChangedLine(page: Page): Promise<void> {
|
||||
await test.step("the whole reviewable row reveals the gutter affordance and uses a text cursor", async () => {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
await test.step("creates, cancels, edits, and deletes a review while keeping Changes focused", async () => {
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("scrolling clears the hovered review affordance", async () => {
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectChangedFileHeaders(page: Page): Promise<void> {
|
||||
await test.step("file statuses follow their diff stats", async () => {
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
await test.step("every interactive file header has the same hover feedback", async () => {
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("canvas file headers select without toggling for context menu and long press", async () => {
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("changes file actions open below the right-click without a reserved kebab", async () => {
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
async function duplicateChangedFiles(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
}
|
||||
|
||||
async function scrollDiffToTop(page: Page): Promise<void> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@ 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,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import {
|
||||
composerLocator,
|
||||
fillComposerDraft,
|
||||
@@ -21,18 +20,20 @@ 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";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_TAIL = "STREAMED-REPLY-FINAL-TAIL";
|
||||
const HYDRATED_PARTIAL = "Hydrated partial continuation";
|
||||
const STREAMED_PREFIX = [
|
||||
const STREAMED_RESPONSE = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].map((paragraph) => `${paragraph}\n\n`);
|
||||
].join("\n\n");
|
||||
|
||||
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';
|
||||
@@ -124,6 +125,49 @@ 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) => {
|
||||
@@ -178,7 +222,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="assistant-message"]',
|
||||
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
@@ -223,6 +267,34 @@ 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,
|
||||
}) => {
|
||||
@@ -286,90 +358,50 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat {
|
||||
cleanup(): Promise<void>;
|
||||
advancePastVisiblePrefix(): Promise<void>;
|
||||
expectHydratedPartial(): Promise<void>;
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/** The provider owns the pause, so both search and hydration see incomplete output. */
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const stream = await createControlledStream();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "chat-find-live-blocks-" }).catch(
|
||||
async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
},
|
||||
);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
};
|
||||
});
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: stream.provider,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Chat Find live blocks",
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await workspace.client.sendAgentMessage(agent.id, "Stream a multi block answer");
|
||||
await stream.append(STREAMED_PREFIX);
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible();
|
||||
return {
|
||||
cleanup,
|
||||
advancePastVisiblePrefix: async () => {
|
||||
const count = gate.getAgentStreamItemCount("assistant_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await stream.append([HYDRATED_PARTIAL]);
|
||||
await gate.waitForAgentStreamItem("assistant_message", count + 1);
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(
|
||||
HYDRATED_PARTIAL,
|
||||
);
|
||||
},
|
||||
expectHydratedPartial: async () => {
|
||||
// A newer daemon row forces Find to fetch the real timeline instead of
|
||||
// matching only the browser's streamed prefix.
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(HYDRATED_PARTIAL);
|
||||
expect(gate.getTimelineRequestCount("before", agent.id)).toBeGreaterThan(0);
|
||||
},
|
||||
...agent,
|
||||
replyFinished: async () => {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
await stream.finish([`\n\n${STREAMED_TAIL}`]);
|
||||
await workspace.client.waitForFinish(agent.id, 15_000);
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectIncompleteStreamedReply(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("beta-needle");
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("filler399");
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(STREAMED_TAIL);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.advancePastVisiblePrefix();
|
||||
await openChatFind(page);
|
||||
// The first-paragraph hit must remain searchable while the same message grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.expectHydratedPartial();
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.replyFinished();
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(STREAMED_TAIL);
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two paragraphs, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import {
|
||||
clickChatOutlineRowEdge,
|
||||
@@ -50,43 +50,6 @@ import {
|
||||
const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
async function navigateLoadedOutline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await test.step("preview across the row", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("jump, dismiss preview, and retain bare selection", async () => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("track the active prompt while jumping and scrolling", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
await test.step("clamp the newest prompt at maximum scroll", async () => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
}
|
||||
|
||||
async function jumpToNewerLoadedPrompt(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
}
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
|
||||
page,
|
||||
@@ -220,14 +183,28 @@ test.describe("desktop chat outline", () => {
|
||||
await expectChatOutlinePrompts(page, LOADED_TURNS);
|
||||
});
|
||||
|
||||
test("previews, jumps, and tracks the reader through loaded prompts", async ({ page }) => {
|
||||
await navigateLoadedOutline(page, agent);
|
||||
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
|
||||
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
|
||||
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
|
||||
});
|
||||
|
||||
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
|
||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
@@ -238,6 +215,32 @@ test.describe("desktop chat outline", () => {
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
});
|
||||
|
||||
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
});
|
||||
|
||||
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
|
||||
test("keeps the current prompt active throughout a long live assistant turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -279,11 +282,21 @@ test.describe("desktop chat outline", () => {
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||
});
|
||||
|
||||
await test.step(
|
||||
"jump downward from old history to a newer loaded prompt",
|
||||
jumpToNewerLoadedPrompt.bind(null, page, agent),
|
||||
);
|
||||
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 30);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -41,93 +42,37 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
test.describe("Command Center agent controls", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("search, select, and re-rank live agent controls before navigating to an action", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-live-controls-",
|
||||
title: "Command Center live controls",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
// A stale runtime mode makes selecting the provider's supported mode
|
||||
// exercise the live setting RPC instead of the selected-row no-op.
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, workspace);
|
||||
await test.step("changes a running agent setting and preserves its selected row", async () => {
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await closeCommandCenter(page);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await test.step("matches query tokens in any order", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
await test.step("activates the best-matching row, not the highest-ranked group", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await test.step("drops an arrow-key selection once the query changes", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await test.step("keeps an action first when the action is the best match", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -155,6 +100,107 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
// model. Asserting both model and mode catches either row winning.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-relevance-",
|
||||
title: "Command Center relevance",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("matches query tokens in any order", async ({ page }) => {
|
||||
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||
// a substring of it. Only per-token matching finds this row.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-tokenized-",
|
||||
title: "Command Center tokenized search",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-action-priority-",
|
||||
title: "Command Center action priority",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-active-reset-",
|
||||
title: "Command Center active reset",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -82,20 +82,31 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
await test.step("dropping the files scope leaves the search row the same height", async () => {
|
||||
await page.keyboard.press("Meta+P");
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
const reopenedPanel = page.getByTestId("command-center-panel");
|
||||
await expect(reopenedPanel).toBeVisible({ timeout: 30_000 });
|
||||
const header = reopenedPanel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
});
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
});
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -107,122 +107,73 @@ async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("manage the active workspace and scope its commands when leaving the route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-manage-" });
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await test.step("renames the active workspace while its sidebar row is unmounted", async () => {
|
||||
await collapseProjectSection(page, workspace);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await test.step("copies the workspace path and confirms with a toast", async () => {
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await test.step("flips the pin entry to Unpin after pinning from the palette", async () => {
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
|
||||
await action(panel, "Unpin").click();
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toHaveCount(0);
|
||||
});
|
||||
await test.step("grouping commands persist across reload and switch back", async () => {
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
});
|
||||
await test.step("lists only the globally-handled toggle off a workspace route", async () => {
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
});
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -232,6 +183,35 @@ test.describe("Command center workspace management", () => {
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
@@ -44,16 +44,6 @@ test.describe("Command center workspaces", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await test.step("single-host results show project and branch without a host label", async () => {
|
||||
const singleHostPanel = await openCommandCenter(page);
|
||||
const singleHostRow = singleHostPanel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(singleHostRow.getByTestId("command-center-workspace-subtitle")).toHaveText(
|
||||
`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
@@ -131,4 +121,38 @@ test.describe("Command center workspaces", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-single-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// No secondary host: with a single host, the host label is gated away.
|
||||
await gotoAppShell(page);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,38 +420,36 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("context details preserve composer focus on hover and leave Find available after keyboard dismissal", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
await test.step("pointer entry and dismissal preserve composer focus", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
test("Find remains available after dismissing context details", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await test.step("keyboard dismissal restores trigger focus and Find remains available", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -105,41 +105,25 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens screenshot and non-ASCII Markdown links and explains missing files", async ({
|
||||
page,
|
||||
}) => {
|
||||
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) [报告](docs/reports/开户赠金.md) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
"[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 mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
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("Open the non-ASCII report named by its Markdown link", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
@@ -152,6 +136,31 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -681,37 +690,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await test.step("render sibling and parent-relative images in Markdown preview", async () => {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const guidePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(guidePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await guidePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
@@ -745,30 +724,84 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("isolates an HTML preview, blocks fetch egress, and refreshes while retaining source access", async ({
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
|
||||
const htmlPath = path.join(workspace.repoPath, "plan.html");
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "plan.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
`<!doctype html><html><head><title>probe</title></head><body>
|
||||
<h1>Visual plan</h1>
|
||||
<h1 id="script-result">Inline script did not run</h1>
|
||||
<p id="network-result">Network request not attempted</p>
|
||||
<p id="document-mode">Standards mode not detected</p>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.getElementById("script-result").textContent = "Inline script ran";
|
||||
if (document.compatMode === "CSS1Compat") {
|
||||
@@ -789,8 +822,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "probe.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Inline script ran" }),
|
||||
).toBeVisible();
|
||||
@@ -801,26 +832,34 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
preview.document.getByText("Network request blocked", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(blockedRequests).toEqual([]);
|
||||
await test.step("isolate the preview from app origin and storage", async () => {
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("refresh a changed HTML plan and switch to source and back", async () => {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "origin.html"),
|
||||
`<!doctype html><html><body>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "origin.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
|
||||
@@ -6,14 +6,20 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
selectNewWorkspaceHost,
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const DRAFT = `Please investigate the workspace startup failure.
|
||||
@@ -23,6 +29,70 @@ Trace the request from the app through the daemon, preserve the existing behavio
|
||||
test.describe("New workspace composer draft", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps the draft when the project changes", async ({ page }) => {
|
||||
const firstProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-a-",
|
||||
});
|
||||
const secondProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-b-",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectKey,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectKey,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await firstProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the draft when the host changes", async ({ page }) => {
|
||||
const project: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-host-",
|
||||
});
|
||||
const secondaryServerId = "new-workspace-draft-secondary-host";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: secondaryServerId,
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
selectNewWorkspaceHost,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -93,11 +90,6 @@ test.describe("New workspace entry points", () => {
|
||||
projectLabel: seeded.projectDisplayName,
|
||||
hostLabel: "localhost",
|
||||
});
|
||||
await test.step("changing hosts preserves the draft", async () => {
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -129,9 +121,6 @@ test.describe("New workspace entry points", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
await test.step("Ctrl+P opens the project picker with search focused", async () => {
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -148,6 +137,20 @@ test.describe("New workspace entry points", () => {
|
||||
await expectNewWorkspaceControlsEnabled(page);
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -217,8 +220,6 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
|
||||
// Manually override the selection to C from inside A's screen. This stale
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
@@ -229,9 +230,6 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
await test.step("changing projects preserves the draft", async () => {
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
});
|
||||
|
||||
// Navigate via B's row icon. B must be preselected — the route project wins
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
|
||||
@@ -88,23 +88,11 @@ async function openNewWorkspaceFromContextProject(
|
||||
await expectNewWorkspaceProjectSelected(page, "Context project");
|
||||
}
|
||||
|
||||
const test = base.extend<
|
||||
{ crossHostProject: CrossHostProjectScenario },
|
||||
{ secondaryDaemon: IsolatedHostDaemon }
|
||||
>({
|
||||
secondaryDaemon: [
|
||||
async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const daemon = await startIsolatedHostDaemon("new-workspace-project-preservation-secondary");
|
||||
try {
|
||||
await provide(daemon);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
crossHostProject: async ({ page: _page, secondaryDaemon }, provide) => {
|
||||
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
|
||||
"new-workspace-project-preservation-secondary",
|
||||
);
|
||||
const contextRepo = await createTempGitRepo("new-workspace-context-");
|
||||
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
@@ -189,6 +177,7 @@ const test = base.extend<
|
||||
await primaryDuplicateRepo.cleanup().catch(() => undefined);
|
||||
await secondaryFallbackRepo.cleanup().catch(() => undefined);
|
||||
await secondarySharedRepo.cleanup().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Regression for "the local / worktree selection in the new workspace is not
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
// Second visit (fresh mount of /new): the worktree choice must stick.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-composer-profile",
|
||||
name: "Composer Profile",
|
||||
command: "/bin/echo",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: the composer is one control in two modes", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
|
||||
profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectChatLaunchSelected,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
patchTerminalProfiles,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Wait for input until project cleanup kills the terminal. A fixed lifetime
|
||||
// can expire before a busy browser attaches or finishes revisiting the composer.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace: launch target memory", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const openNewWorkspace = () =>
|
||||
openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "remembered: remember me");
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
// The chat handoff navigates asynchronously (draft -> created agent). Let
|
||||
// it fully settle before moving on, so it can't fire a late redirect
|
||||
// that races the next step's navigation.
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
await patchTerminalProfiles([]);
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
@@ -16,12 +15,6 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
expectChatLaunchSelected,
|
||||
patchTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectTerminalPreviewCommand,
|
||||
@@ -74,50 +67,21 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("switch launch modes, run prompt and bare profiles, and remember successful targets", async ({
|
||||
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await test.step("open New workspace to choose a launch target", async () => {
|
||||
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
});
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
await test.step("submit the prompt profile", async () => {
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
|
||||
await fillTerminalPrompt(page, "fix the flaky test");
|
||||
await submitTerminalLaunch(page);
|
||||
});
|
||||
@@ -132,7 +96,6 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
|
||||
@@ -144,31 +107,6 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
await test.step("successful launches remember their target across visits", async () => {
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectTerminalLaunchSelected(page, BARE_PROFILE.name);
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
await test.step("a removed remembered terminal profile falls back to Chat", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
try {
|
||||
await patchTerminalProfiles([]);
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
} finally {
|
||||
await patchTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||
@@ -219,10 +157,6 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -172,9 +172,7 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("workspace button and shortcut follow the active project across visits", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -185,15 +183,6 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await test.step("global button follows project B", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -202,15 +191,27 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await test.step("global button follows project A", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
expectWorkspaceIsolationSelected,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
@@ -725,26 +723,9 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
await test.step("cancel the branch picker without changing the selection", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
await test.step("select the filtered starting branch with the keyboard", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
@@ -768,10 +749,6 @@ test.describe("New workspace flow", () => {
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
await test.step("the worktree choice survives creating and reopening New workspace", async () => {
|
||||
await openNewWorkspaceComposer(page, openedProject);
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
@@ -913,6 +890,57 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
|
||||
@@ -226,19 +226,10 @@ test.describe("narrow touch browser", () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await test.step("reveal the first match below the floating Find widget", async () => {
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await test.step("tap the next uncovered match while controls stay inside the pane", async () => {
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
});
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
@@ -341,6 +332,33 @@ for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -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 agent prompt",
|
||||
label: "Send message",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -188,3 +188,142 @@ async function measureInlineCode(message: Locator) {
|
||||
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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,6 +749,7 @@ function observePluginReload(page: Page) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -813,6 +814,12 @@ async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string
|
||||
});
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -826,9 +833,6 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Advance the real browser client's RPC timers while the daemon's build gate
|
||||
// remains held on its own clock.
|
||||
await page.clock.install();
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
@@ -847,7 +851,7 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await page.clock.fastForward(66_000);
|
||||
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.
|
||||
|
||||
@@ -316,9 +316,7 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens a GitHub-backed workspace with folder metadata and pointer and keyboard hover cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
@@ -337,46 +335,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row).toContainText("main");
|
||||
|
||||
await test.step("inspect host metadata before opening the workspace", async () => {
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
});
|
||||
|
||||
await test.step("open the workspace and inspect its selected folder header", async () => {
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("dismiss a clicked row's hover card when the pointer leaves", async () => {
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("retain keyboard preview until Escape or focus leaves the row", async () => {
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -396,6 +354,107 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
||||
"main",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card shows host as metadata", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
@@ -436,21 +495,26 @@ test.describe("Sidebar workspace list", () => {
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("opens mobile navigation beside the close button and closes it again", async ({ page }) => {
|
||||
test("showMobileAgent open and close transition", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await test.step("place the close button beside the first navigation row", async () => {
|
||||
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);
|
||||
});
|
||||
await closeMobileAgentSidebar(page);
|
||||
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,
|
||||
}) => {
|
||||
@@ -523,14 +587,15 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("start with pinned navigation at half of a 14-inch Mac display", async () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -588,6 +589,69 @@ 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(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "../support/fixtures";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
@@ -28,46 +27,31 @@ interface ViewedTimelineScenario {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
type ViewedAgentConfig = Pick<
|
||||
Parameters<SeedDaemonClient["createAgent"]>[0],
|
||||
"provider" | "modeId" | "model"
|
||||
>;
|
||||
|
||||
async function seedViewedTimelineScenario(
|
||||
options: { firstAgent?: ViewedAgentConfig } = {},
|
||||
options: { firstAgentModel?: string } = {},
|
||||
): Promise<ViewedTimelineScenario> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
||||
const createAgent = (
|
||||
title: string,
|
||||
config: ViewedAgentConfig = {
|
||||
provider: "mock",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
},
|
||||
) =>
|
||||
const createAgent = (title: string, model = "ten-second-stream") =>
|
||||
workspace.client.createAgent({
|
||||
...config,
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model,
|
||||
});
|
||||
try {
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgent),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgentModel),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
interface RestoredLayoutChat {
|
||||
@@ -322,39 +306,19 @@ test.describe("Viewed agent timelines", () => {
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const stream = await createControlledStream();
|
||||
const scenario = await seedViewedTimelineScenario({
|
||||
firstAgent: { provider: stream.provider },
|
||||
}).catch(async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
});
|
||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
|
||||
await stream.append(["The first chat is still producing output."]);
|
||||
await expect(
|
||||
page.getByText("The first chat is still producing output.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
const agents = await scenario.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find((entry) => entry.agent.id === scenario.firstAgentId)?.agent.status,
|
||||
).toBe("running");
|
||||
await stream.finish([" Completed while hidden."]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expectAgentConsistentlyIdle(page, "First viewed chat");
|
||||
await expect(page.getByText("Completed while hidden.", { exact: false })).toBeVisible();
|
||||
} finally {
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
/** A single-turn producer controlled through the daemon, before timeline persistence. */
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
let startTurn!: (append: (chunks: string[], finish: boolean) => void) => void;
|
||||
const turn = new Promise<(chunks: string[], finish: boolean) => void>((resolve) => {
|
||||
startTurn = resolve;
|
||||
});
|
||||
server.handle(
|
||||
defineRpc({
|
||||
name: "append",
|
||||
input: z.object({ chunks: z.array(z.string()), finish: z.boolean() }),
|
||||
output: z.null(),
|
||||
}),
|
||||
async ({ chunks, finish }) => {
|
||||
const append = await turn;
|
||||
append(chunks, finish);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
server.registerProvider(
|
||||
runAcpProvider({ id: "controlled-stream", label: "Controlled stream", connector: connect }),
|
||||
);
|
||||
return () => {};
|
||||
|
||||
function connect() {
|
||||
let controller: ReadableStreamDefaultController<AcpStreamMessage>;
|
||||
return {
|
||||
readable: new ReadableStream<AcpStreamMessage>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream<AcpStreamMessage>({
|
||||
write(message) {
|
||||
if (!("method" in message) || !("id" in message)) return;
|
||||
if (message.method === "session/prompt") {
|
||||
let finished = false;
|
||||
startTurn((chunks, finish) => {
|
||||
if (finished) return;
|
||||
for (const text of chunks) {
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "controlled",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (finish) {
|
||||
finished = true;
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { stopReason: "end_turn" },
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let result: unknown = {};
|
||||
if (message.method === "initialize") {
|
||||
result = { protocolVersion: 1, agentCapabilities: {} };
|
||||
} else if (message.method === "session/new") {
|
||||
result = { sessionId: "controlled" };
|
||||
}
|
||||
controller.enqueue({ jsonrpc: "2.0", id: message.id, result });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } }
|
||||
@@ -1,57 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
|
||||
type ControlledStreamClient = Pick<
|
||||
DaemonClient,
|
||||
| "connect"
|
||||
| "close"
|
||||
| "getDaemonConfig"
|
||||
| "patchDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "removePlugin"
|
||||
| "invokePluginRpc"
|
||||
>;
|
||||
|
||||
/** Owns one real provider turn whose completion the test explicitly releases. */
|
||||
export async function createControlledStream() {
|
||||
const client = await connectDaemonClient<ControlledStreamClient>({
|
||||
clientIdPrefix: "controlled-stream",
|
||||
});
|
||||
const previous = await client.getDaemonConfig();
|
||||
let appended = false;
|
||||
const emit = (chunks: string[], finish: boolean) =>
|
||||
client.invokePluginRpc("controlled-stream-test", "append", { chunks, finish });
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
if (appended) await emit([], true);
|
||||
await client.removePlugin("controlled-stream-test");
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../fixtures/controlled-stream-plugin"),
|
||||
);
|
||||
return {
|
||||
provider: "controlled-stream",
|
||||
append: async (chunks: string[]) => {
|
||||
await emit(chunks, false);
|
||||
appended = true;
|
||||
},
|
||||
finish: async (chunks: string[] = []) => {
|
||||
await emit(chunks, true);
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).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);
|
||||
}
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// 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 currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// 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
|
||||
? 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;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 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 [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
@@ -13,6 +12,27 @@ export const scrollCadences = [
|
||||
] as const;
|
||||
|
||||
type Cadence = (typeof scrollCadences)[number];
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
interface Recording {
|
||||
frames: ScrollFrame[];
|
||||
finishInput(): void;
|
||||
@@ -384,6 +404,80 @@ export async function recordUpwardTraversal(
|
||||
});
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// 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 currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// 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
|
||||
? 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;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 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 [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportScrollJumps(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
|
||||
@@ -135,3 +135,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -118,8 +118,8 @@ export function ChatFind({
|
||||
}, [active, isInteractive, model]);
|
||||
const close = useCallback(() => {
|
||||
model.close();
|
||||
root.current?.focus({ preventScroll: true });
|
||||
}, [model]);
|
||||
viewportRef.current?.focus?.();
|
||||
}, [model, viewportRef]);
|
||||
let status = "";
|
||||
if (state.phase === "searching") status = t("paneFind.searching");
|
||||
else if (state.phase === "loading") status = t("paneFind.loading");
|
||||
|
||||
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
|
||||
]);
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -21,28 +21,55 @@ 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 groups.values()) {
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -64,6 +65,7 @@ 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;
|
||||
@@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// A history row can contain an entire long reply. Keep synchronous native
|
||||
// Markdown measurement bounded while FlatList fills the surrounding window.
|
||||
// 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={2}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
|
||||
scrollToBottom: () => {
|
||||
setFollowOutput(true);
|
||||
cancelPendingStickToBottom();
|
||||
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
|
||||
}
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
|
||||
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
serverId={resolvedServerId}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -795,7 +796,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
// Read through a stable event so live group updates do not change the renderer identity
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
@@ -112,7 +114,7 @@ it.each([
|
||||
"src/file.ts:10-20",
|
||||
"src/file.ts(10,2)",
|
||||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
|
||||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||||
href: path,
|
||||
@@ -121,6 +123,106 @@ it.each([
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stable inline-code file classification through the library parser", async () => {
|
||||
const paths = [
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"src/index.ts:20-10",
|
||||
"src/index.ts#L0",
|
||||
"src/index.ts?raw",
|
||||
"before/after",
|
||||
"word.word",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"src/file.ts lines 10-20",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
];
|
||||
const source = paths.map((path) => `\`${path}\``).join(" and ");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(links).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
]);
|
||||
expect(
|
||||
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
|
||||
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
|
||||
});
|
||||
|
||||
it("lets the engine own code boundaries while classifying candidate values", async () => {
|
||||
const source = [
|
||||
"```ts\n`src/fenced.ts`\n```",
|
||||
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
|
||||
"``src/a`b.ts`` and `src/after.ts`",
|
||||
"\\`src/escaped.ts` and `src/final.ts`",
|
||||
"`\nsrc/newline.ts\n`",
|
||||
"[`src/explicit.ts`](https://example.com)",
|
||||
].join("\n\n");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const recognized: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
|
||||
recognized.push(String(node.attributes.url));
|
||||
}
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(recognized).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
" src/spaced.ts ",
|
||||
"src/a`b.ts",
|
||||
"src/after.ts",
|
||||
"src/escaped.ts",
|
||||
"src/final.ts",
|
||||
"src/newline.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||||
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
|
||||
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
|
||||
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
|
||||
expect(withoutWorkspace.test("README.md")).toBe(false);
|
||||
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const native = toNativeLinkRecognition(prepared.linkRecognition);
|
||||
const pattern = new RegExp(native.inlineCode.pattern);
|
||||
expect(pattern.test("src/app/[id].tsx")).toBe(true);
|
||||
expect(pattern.test("src/a+b.ts")).toBe(true);
|
||||
expect(pattern.test("src/app/i.tsx")).toBe(false);
|
||||
expect(pattern.test("src/ab.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||||
@@ -489,7 +591,7 @@ it.each([
|
||||
"×/÷",
|
||||
"2024/10/16",
|
||||
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
|
||||
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
@@ -514,12 +616,11 @@ it.each([
|
||||
"123.ts",
|
||||
"FILE.TS",
|
||||
"https://example.com/path",
|
||||
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
|
||||
])("recognizes complete paths and URLs in prose: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
|
||||
expect(
|
||||
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
|
||||
).toEqual([token]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -530,10 +631,13 @@ it.each([
|
||||
"packages/app",
|
||||
"123/file",
|
||||
"src/123",
|
||||
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
|
||||
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(
|
||||
`Compare ${token}, then \`${token}\`.`,
|
||||
"/repo",
|
||||
);
|
||||
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from "react-native";
|
||||
import { forwardRef, useMemo, type ComponentProps } from "react";
|
||||
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
|
||||
import type {
|
||||
CodeBlockRendererProps,
|
||||
LinkRendererProps,
|
||||
@@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
|
||||
}
|
||||
|
||||
// The library styles links inline, so a stylesheet :hover rule cannot underline them.
|
||||
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
|
||||
|
||||
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
|
||||
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleMouseEnter = useCallback(() => setHovered(true), []);
|
||||
const handleMouseLeave = useCallback(() => setHovered(false), []);
|
||||
const anchorStyle = useMemo(
|
||||
() => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }),
|
||||
[anchorProps.style],
|
||||
() => ({
|
||||
...anchorProps.style,
|
||||
overflowWrap: "anywhere" as const,
|
||||
...(hovered ? LINK_HOVER_STYLE : null),
|
||||
}),
|
||||
[anchorProps.style, hovered],
|
||||
);
|
||||
const anchor = (
|
||||
<a {...anchorProps} style={anchorStyle}>
|
||||
<a
|
||||
{...anchorProps}
|
||||
style={anchorStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
|
||||
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
|
||||
// Bare prose paths need a known extension, a line suffix, or at least two separators.
|
||||
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
|
||||
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
|
||||
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
|
||||
}
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
|
||||
|
||||
function linkMetadata() {
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
// first, then pass literal matches rather than maintaining a second path grammar.
|
||||
const alternatives = [...inlineCodePaths].map((path) =>
|
||||
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
return {
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
|
||||
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
|
||||
),
|
||||
},
|
||||
linkSource: markdownLinkSource,
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
// Markdown context (escapes, fences, authored links); extra candidates are harmless
|
||||
// because recognition only applies to complete Code nodes. This avoids an AST
|
||||
// parse on every streaming reveal, including updates inside long code fences.
|
||||
for (const match of source.matchAll(/`+/g)) {
|
||||
const length = match[0].length;
|
||||
const previousEnd = previousEnds.get(length);
|
||||
previousEnds.set(length, match.index + length);
|
||||
if (previousEnd === undefined) continue;
|
||||
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
|
||||
// CommonMark code spans replace line endings and strip one surrounding space.
|
||||
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function markdownLinkSource(
|
||||
url: string,
|
||||
sourceType?: "markdown" | "text" | "inline-code",
|
||||
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes(")
|
||||
) {
|
||||
return { images, ...linkMetadata(), render: () => source };
|
||||
return { images, ...metadata, render: () => source };
|
||||
}
|
||||
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
@@ -206,7 +233,7 @@ export function prepareMarkdownSource(
|
||||
visit(getTree());
|
||||
return {
|
||||
images,
|
||||
...linkMetadata(),
|
||||
...metadata,
|
||||
render(
|
||||
urls: ReadonlyMap<string, string> = new Map(),
|
||||
unavailableImages: ReadonlySet<string> = new Set(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
serverId?: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -2228,6 +2230,7 @@ interface ToolCallProps {
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
serverId,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
@@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail: effectiveDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
targetAgentTitle,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -2393,6 +2398,8 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.toolName !== next.toolName) return false;
|
||||
if (previous.serverId !== next.serverId) return false;
|
||||
if (previous.agentMessage !== next.agentMessage) return false;
|
||||
if (previous.args !== next.args) return false;
|
||||
if (previous.result !== next.result) return false;
|
||||
if (previous.error !== next.error) return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailSection,
|
||||
} from "@getpaseo/protocol/paseo-tool-call-detail";
|
||||
} from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
|
||||
@@ -41,6 +41,93 @@ function rowWithText(
|
||||
}
|
||||
|
||||
describe("native terminal selection", () => {
|
||||
it.each([
|
||||
{ startCol: 1, endCol: 8, expected: "한글 문장" },
|
||||
{ startCol: 3, endCol: 8, expected: "글 문장" },
|
||||
{ startCol: 1, endCol: 1, expected: "한" },
|
||||
{ startCol: 0, endCol: 0, expected: "한" },
|
||||
{ startCol: 0, endCol: 1, expected: "한" },
|
||||
{ startCol: 4, endCol: 6, expected: " 문" },
|
||||
])(
|
||||
"copies complete wide characters for selected columns $startCol through $endCol",
|
||||
async ({ startCol, endCol, expected }) => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
const copied: string[] = [];
|
||||
try {
|
||||
await terminal.write("한글 문장");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
const selection = createTerminalSelectionModel();
|
||||
selection.begin({
|
||||
coordinate: { row: bounds.cursorRow, col: endCol },
|
||||
bounds,
|
||||
});
|
||||
selection.update({
|
||||
coordinate: { row: bounds.cursorRow, col: startCol },
|
||||
bounds,
|
||||
});
|
||||
|
||||
await copyTerminalSelection({
|
||||
terminal,
|
||||
selection: selection.getSnapshot().range,
|
||||
clipboard: {
|
||||
writeText: async (text) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(copied).toEqual([expected]);
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("copies Korean output without adding spaces for continuation cells", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
const copied: string[] = [];
|
||||
try {
|
||||
await terminal.write("한글 문장");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
await copyTerminalSelection({
|
||||
terminal,
|
||||
selection: {
|
||||
start: { row: bounds.cursorRow, col: 0 },
|
||||
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
|
||||
coordinateEpoch: bounds.coordinateEpoch,
|
||||
},
|
||||
clipboard: {
|
||||
writeText: async (text) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(copied).toEqual(["한글 문장"]);
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
try {
|
||||
await terminal.write("한글 문장 | 성공");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
expect(
|
||||
extractTerminalSelectedText({
|
||||
terminal,
|
||||
selection: {
|
||||
start: { row: bounds.cursorRow, col: 0 },
|
||||
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
|
||||
coordinateEpoch: bounds.coordinateEpoch,
|
||||
},
|
||||
}),
|
||||
).toBe("한글 문장 | 성공");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("expands a long-press coordinate to the whole terminal word", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
|
||||
await terminal.write("before PASEO_TARGET after\r\n");
|
||||
|
||||
@@ -324,10 +324,17 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
|
||||
const cells = window.rows[offset] ?? [];
|
||||
const startsOnThisRow = row === selection.start.row;
|
||||
const endsOnThisRow = row === selection.end.row;
|
||||
const startCol = startsOnThisRow ? selection.start.col : 0;
|
||||
let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0);
|
||||
// Selecting either half highlights the whole wide character, so copy its leading cell too.
|
||||
if (startCol > 0 && cells[startCol]?.width === 0) {
|
||||
startCol -= 1;
|
||||
}
|
||||
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
|
||||
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
|
||||
const line = selectedCells.map((cell) => cell.char || " ").join("");
|
||||
const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1);
|
||||
const line = selectedCells
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join("");
|
||||
if (offset > 0 && window.wrappedRows[offset] !== true) {
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
@@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void {
|
||||
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
|
||||
|
||||
describe("viewed timeline persistence", () => {
|
||||
it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const partial = "Paragraph 1. Paragraph 2.";
|
||||
const complete = `${partial} Paragraph 3. END`;
|
||||
const owner = createOwner({
|
||||
readTimeline: async () => ({
|
||||
agentId: AGENT_ID,
|
||||
items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
}),
|
||||
commitTimeline: () => undefined,
|
||||
});
|
||||
try {
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
|
||||
.status,
|
||||
)
|
||||
.toBe("painted");
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:01.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
owner.applyTimelineResponse({
|
||||
requestId: "resume",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
|
||||
startCursor: { epoch: "epoch-1", seq: 5 },
|
||||
endCursor: { epoch: "epoch-1", seq: 6 },
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: complete, messageId: "reply-1" },
|
||||
timestamp: "2026-08-26T10:00:02.000Z",
|
||||
seqStart: 2,
|
||||
seqEnd: 6,
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
});
|
||||
const replyTexts = () => {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return [
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]
|
||||
.filter((entry) => entry.kind === "assistant_message")
|
||||
.map((entry) => entry.text);
|
||||
};
|
||||
expect.soft(replyTexts()).toEqual([complete]);
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
|
||||
seq: 7,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:03.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
expect.soft(replyTexts()).toEqual([complete]);
|
||||
} finally {
|
||||
owner.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("shares an in-flight cache preparation with the viewed owner", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
@@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: {
|
||||
};
|
||||
|
||||
const headMatch = findMatch(params.head);
|
||||
const tailMatch = headMatch ? null : findMatch(params.tail);
|
||||
const tailMatch = findMatch(params.tail);
|
||||
const match = headMatch ?? tailMatch;
|
||||
if (!match) {
|
||||
return { tail: params.tail, head: params.head, reconciled: false };
|
||||
@@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: {
|
||||
};
|
||||
|
||||
if (headMatch) {
|
||||
const tailCursor = tailMatch?.current.timelineCursor;
|
||||
const replacesCachedPrefix =
|
||||
tailMatch !== null &&
|
||||
tailCursor?.epoch === params.epoch &&
|
||||
unit.sourceSeqRanges.some(
|
||||
(range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq,
|
||||
);
|
||||
return {
|
||||
tail: params.tail,
|
||||
// Live chunks can arrive before catch-up while their cached prefix is still painted.
|
||||
// The projected reply owns both copies; keeping the prefix would duplicate it and
|
||||
// cause completion to discard the updated head as an already-committed item.
|
||||
tail: replacesCachedPrefix
|
||||
? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)]
|
||||
: params.tail,
|
||||
head: replaceMatch(params.head, headMatch.index),
|
||||
reconciled: true,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
|
||||
|
||||
const fakeIcons = {
|
||||
paseo: (() => null) as ToolCallPresentationIcon,
|
||||
brain: (() => null) as ToolCallPresentationIcon,
|
||||
eye: (() => null) as ToolCallPresentationIcon,
|
||||
wrench: (() => null) as ToolCallPresentationIcon,
|
||||
@@ -12,7 +14,11 @@ const fakeIcons = {
|
||||
function fakeResolveIcon(
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
agentMessage: AgentMessage | undefined,
|
||||
): ToolCallPresentationIcon {
|
||||
if (agentMessage) {
|
||||
return fakeIcons.paseo;
|
||||
}
|
||||
if (detail?.type === "plan") {
|
||||
return fakeIcons.brain;
|
||||
}
|
||||
@@ -94,32 +100,35 @@ describe("tool-call presentation", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["message", "Message from QA messenger"],
|
||||
["finished", "QA messenger finished"],
|
||||
["errored", "QA messenger errored"],
|
||||
["permission-required", "QA messenger needs permission"],
|
||||
["closed", "QA messenger closed"],
|
||||
] as const)("presents %s from structured provenance without a second label", (event, heading) => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "internal_name_should_not_be_visible",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
|
||||
agentMessage: {
|
||||
event,
|
||||
sender: { id: "server::sender", title: "QA messenger" },
|
||||
text: "The delivered message.",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe(heading);
|
||||
expect(presentation.summary).toBeUndefined();
|
||||
expect(presentation.detail).toEqual({
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: "From agent: server::sender\n\nThe delivered message.",
|
||||
});
|
||||
});
|
||||
["message", "Message received"],
|
||||
["finished", "Agent finished"],
|
||||
["errored", "Agent errored"],
|
||||
["permission-required", "Agent needs permission"],
|
||||
["closed", "Agent closed"],
|
||||
] as const)(
|
||||
"presents %s with a fixed label, the sender as context, and the Paseo icon",
|
||||
(event, heading) => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "internal_name_should_not_be_visible",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "legacy summary", text: "legacy body", icon: "bot" },
|
||||
agentMessage: {
|
||||
event,
|
||||
sender: { id: "server::sender", title: "QA messenger" },
|
||||
text: "The delivered message.",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe(heading);
|
||||
expect(presentation.summary).toBe("QA messenger");
|
||||
expect(presentation.icon).toBe(fakeIcons.paseo);
|
||||
expect(presentation.detail).toEqual({
|
||||
type: "plain_text",
|
||||
text: "From agent: server::sender\n\nThe delivered message.",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses an opaque sender ID when no title is available", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
@@ -129,5 +138,19 @@ it("uses an opaque sender ID when no title is available", () => {
|
||||
agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe("Message from remote::worker");
|
||||
expect(presentation.displayName).toBe("Message received");
|
||||
expect(presentation.summary).toBe("remote::worker");
|
||||
});
|
||||
|
||||
it("shows the target agent title as context for Paseo agent tools", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "mcp__paseo__kill_agent",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: { agentId: "agt_1" }, output: null },
|
||||
targetAgentTitle: "Reviewer",
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe("Kill agent");
|
||||
expect(presentation.summary).toBe("Reviewer");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import type { PlanOutcome } from "@/components/plan-card";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -22,6 +22,7 @@ interface BuildToolCallPresentationInput {
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
targetAgentTitle?: string;
|
||||
resolveIcon: ToolCallIconResolver;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export interface ToolCallPresentation {
|
||||
export type ToolCallIconResolver = (
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
agentMessage: AgentMessage | undefined,
|
||||
) => ToolCallPresentationIcon;
|
||||
|
||||
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
|
||||
@@ -67,6 +69,7 @@ export function buildToolCallPresentation(
|
||||
detail: detailForDisplay,
|
||||
metadata: input.metadata,
|
||||
cwd: input.cwd,
|
||||
targetAgentTitle: input.targetAgentTitle,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail,
|
||||
@@ -80,7 +83,7 @@ export function buildToolCallPresentation(
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, detail),
|
||||
icon: input.resolveIcon(input.toolName, detail, input.agentMessage),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { getPaseoToolTargetAgentId } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
/** Title of the agent a Paseo tool call acts on, when this host knows it. */
|
||||
export function useTargetAgentTitle(input: {
|
||||
serverId: string | undefined;
|
||||
toolName: string;
|
||||
detail: ToolCallDetail | undefined;
|
||||
}): string | undefined {
|
||||
const agentId = getPaseoToolTargetAgentId(input.toolName, input.detail);
|
||||
const { serverId } = input;
|
||||
return useSessionStore((state) => {
|
||||
if (!serverId || !agentId) return undefined;
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return agent?.title ?? undefined;
|
||||
});
|
||||
}
|
||||
@@ -41,11 +41,17 @@ function fillToneStyle(tone: UsageTone) {
|
||||
}
|
||||
}
|
||||
|
||||
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
|
||||
export function UsageBalanceBar({
|
||||
balance,
|
||||
retained = false,
|
||||
}: {
|
||||
balance: UsageBalance;
|
||||
retained?: boolean;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { formatUsageFreshness, type UsageRefresh } from "./model";
|
||||
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
|
||||
@@ -27,31 +27,6 @@ function statusText(report: UsageReport): string | null {
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function reportContent(report: UsageReport) {
|
||||
if (report.status === "available")
|
||||
return {
|
||||
windows: report.windows,
|
||||
balances: report.balances ?? [],
|
||||
details: report.details ?? [],
|
||||
message: undefined,
|
||||
};
|
||||
const message =
|
||||
report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error;
|
||||
return { windows: [], balances: [], details: [], message };
|
||||
}
|
||||
|
||||
function reportMessages(entry: UsageReportEntry): string[] {
|
||||
if (entry.report.status === "available") return [];
|
||||
if (entry.loginErrors)
|
||||
return entry.loginErrors.map(
|
||||
(login) => `${login.harness}: ${reportContent(login.report).message}`,
|
||||
);
|
||||
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
||||
return [
|
||||
entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem),
|
||||
];
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
@@ -81,8 +56,8 @@ export function UsageCard({
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details } = reportContent(usage);
|
||||
const messages = reportMessages(entry);
|
||||
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
|
||||
resolveUsageReport(entry);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -104,9 +79,7 @@ export function UsageCard({
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.status === "available" && usage.planLabel ? (
|
||||
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
|
||||
) : null}
|
||||
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
@@ -117,7 +90,8 @@ export function UsageCard({
|
||||
{refreshable ? (
|
||||
<UsageRefreshButton
|
||||
sourceLabel={entry.sourceLabel}
|
||||
fetchedAt={entry.fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
refreshState={refreshState}
|
||||
onRefresh={refresh}
|
||||
compact={isCompact}
|
||||
@@ -138,12 +112,13 @@ export function UsageCard({
|
||||
key={window.id}
|
||||
entry={entry}
|
||||
window={window}
|
||||
retained={retained}
|
||||
display={display}
|
||||
pinnable={pinnable}
|
||||
/>
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<UsageBalanceBar key={balance.id} balance={balance} />
|
||||
<UsageBalanceBar key={balance.id} balance={balance} retained={retained} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
@@ -170,7 +145,8 @@ export function UsageCard({
|
||||
</Text>
|
||||
{showsFreshnessInline ? (
|
||||
<UsageFreshness
|
||||
fetchedAt={entry.fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
style={styles.freshness}
|
||||
testID="usage-freshness"
|
||||
/>
|
||||
@@ -192,7 +168,9 @@ function CardWindowBar({
|
||||
window,
|
||||
display,
|
||||
pinnable,
|
||||
retained,
|
||||
}: {
|
||||
retained: boolean;
|
||||
entry: UsageReportEntry;
|
||||
window: UsageWindow;
|
||||
display: UsageDisplay;
|
||||
@@ -207,6 +185,7 @@ function CardWindowBar({
|
||||
return (
|
||||
<UsageWindowBar
|
||||
window={window}
|
||||
retained={retained}
|
||||
displayAs={display.displayAs}
|
||||
pinnable={pinnable}
|
||||
pinned={display.isPinned(pin)}
|
||||
@@ -220,13 +199,15 @@ function CardWindowBar({
|
||||
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
|
||||
function UsageRefreshButton({
|
||||
sourceLabel,
|
||||
fetchedAt,
|
||||
successfulAt,
|
||||
retained,
|
||||
refreshState,
|
||||
onRefresh,
|
||||
compact,
|
||||
}: {
|
||||
sourceLabel: string;
|
||||
fetchedAt: string;
|
||||
successfulAt: string | null;
|
||||
retained: boolean;
|
||||
refreshState: UsageRefresh;
|
||||
onRefresh: () => void;
|
||||
compact: boolean;
|
||||
@@ -236,12 +217,13 @@ function UsageRefreshButton({
|
||||
const freshness = useMemo(
|
||||
() => (
|
||||
<UsageFreshness
|
||||
fetchedAt={fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
style={styles.tooltipText}
|
||||
testID="usage-freshness-tooltip"
|
||||
/>
|
||||
),
|
||||
[fetchedAt],
|
||||
[successfulAt, retained],
|
||||
);
|
||||
return (
|
||||
<ToolbarButton
|
||||
@@ -265,18 +247,20 @@ function UsageRefreshButton({
|
||||
|
||||
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
|
||||
function UsageFreshness({
|
||||
fetchedAt,
|
||||
successfulAt,
|
||||
retained,
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
fetchedAt: string;
|
||||
successfulAt: string | null;
|
||||
retained: boolean;
|
||||
style: StyleProp<TextStyle>;
|
||||
testID: string;
|
||||
}) {
|
||||
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
|
||||
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
|
||||
return (
|
||||
<Text style={style} numberOfLines={1} testID={testID}>
|
||||
{formatUsageFreshness(elapsed)}
|
||||
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount } from "./format";
|
||||
import { formatAmount, formatResetLabel } from "./format";
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("groups thousands in the app's language", () => {
|
||||
@@ -13,3 +13,10 @@ describe("formatAmount", () => {
|
||||
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
|
||||
});
|
||||
});
|
||||
|
||||
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
||||
const past = "2000-01-01T00:00:00.000Z";
|
||||
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
||||
expect(formatResetLabel(past)).toBe("resetting now");
|
||||
expect(formatResetLabel(null, true)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -27,10 +27,11 @@ function relativeDuration(iso: string): string | null {
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
export function formatResetLabel(iso: string | null | undefined): string | null {
|
||||
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso);
|
||||
if (!rel) return null;
|
||||
if (rel === "now" && retained) return "Reset time passed";
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveUsageReport,
|
||||
displayPercent,
|
||||
formatUsageFreshness,
|
||||
resolveAgentUsageView,
|
||||
@@ -325,3 +326,72 @@ describe("resolveAgentUsageView", () => {
|
||||
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves retained usage and successful time without inventing old-daemon history", () => {
|
||||
const success = entry({ windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] });
|
||||
const failure: UsageReportEntry = {
|
||||
...success,
|
||||
fetchedAt: "2026-01-01T00:06:00.000Z",
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
lastSuccessful: {
|
||||
fetchedAt: success.fetchedAt,
|
||||
report: success.report as Extract<UsageReportEntry["report"], { status: "available" }>,
|
||||
},
|
||||
};
|
||||
expect(resolveUsageReport(failure)).toEqual({
|
||||
windows: success.report.status === "available" ? success.report.windows : [],
|
||||
balances: [],
|
||||
details: [],
|
||||
planLabel: null,
|
||||
successfulAt: success.fetchedAt,
|
||||
retained: true,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
planLabel: null,
|
||||
successfulAt: null,
|
||||
retained: false,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport(success)).toEqual({
|
||||
windows: success.report.status === "available" ? success.report.windows : [],
|
||||
balances: [],
|
||||
details: [],
|
||||
planLabel: null,
|
||||
successfulAt: success.fetchedAt,
|
||||
retained: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("orders failed polls by attempt time even when successful data is older", () => {
|
||||
const success = entry({});
|
||||
if (success.report.status !== "available") throw new Error("fixture must be available");
|
||||
const failure: UsageReportEntry = {
|
||||
...success,
|
||||
fetchedAt: "2026-01-01T00:06:00.000Z",
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
lastSuccessful: { fetchedAt: success.fetchedAt, report: success.report },
|
||||
};
|
||||
expect(upsertReport([failure], success)).toEqual([failure]);
|
||||
expect(settleReports([failure], [success])).toEqual([failure]);
|
||||
expect(upsertReport([success], failure)).toEqual([failure]);
|
||||
});
|
||||
|
||||
it("available and retained usage show the same successful completion time", () => {
|
||||
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
|
||||
if (success.report.status !== "available") throw new Error("fixture must be available");
|
||||
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
|
||||
const failure: UsageReportEntry = {
|
||||
...success,
|
||||
successfulAt: undefined,
|
||||
fetchedAt: "2026-01-01T00:06:00.000Z",
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
|
||||
};
|
||||
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
|
||||
expect(upsertReport([failure], success)).toEqual([failure]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
|
||||
export function usedPercent(window: UsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
@@ -32,8 +32,8 @@ export function usageWindowRowLabel(input: {
|
||||
}
|
||||
|
||||
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
|
||||
export function formatUsageFreshness(compactTimeAgo: string): string {
|
||||
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
|
||||
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
}
|
||||
|
||||
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
|
||||
@@ -192,3 +192,40 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
|
||||
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
|
||||
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
||||
}
|
||||
|
||||
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
||||
export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
const available = entry.report.status === "available";
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
const snapshot = available ? null : (entry.lastSuccessful ?? null);
|
||||
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
|
||||
const messages: string[] = [];
|
||||
if (!available) {
|
||||
if (entry.loginErrors)
|
||||
messages.push(
|
||||
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
|
||||
);
|
||||
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
||||
else messages.push(usageProblemText(entry.report));
|
||||
}
|
||||
return {
|
||||
windows: content?.windows ?? [],
|
||||
balances: content?.balances ?? [],
|
||||
details: content?.details ?? [],
|
||||
planLabel: content?.planLabel ?? null,
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Old daemons expose only attempt time for available reports, never for failures.
|
||||
successfulAt: available
|
||||
? (entry.successfulAt ?? entry.fetchedAt)
|
||||
: (snapshot?.fetchedAt ?? null),
|
||||
retained: snapshot !== null,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
function usageProblemText(report: UsageReport): string {
|
||||
if (report.status === "error") return report.error;
|
||||
if (report.status === "unavailable") return usageCopy.problem(report.problem);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -257,9 +257,12 @@ describe("resolvePinnedUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unavailable and error reports out of the sidebar summary", () => {
|
||||
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
|
||||
if (claude.report.status !== "available" || codex.report.status !== "available")
|
||||
throw new Error("fixtures must be available");
|
||||
const expired: UsageReportEntry = {
|
||||
...claude,
|
||||
lastSuccessful: { fetchedAt: claude.fetchedAt, report: claude.report },
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" },
|
||||
@@ -267,6 +270,7 @@ it("keeps unavailable and error reports out of the sidebar summary", () => {
|
||||
};
|
||||
const failed: UsageReportEntry = {
|
||||
...codex,
|
||||
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
|
||||
report: { status: "error", error: "Store deleted" },
|
||||
};
|
||||
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
|
||||
|
||||
@@ -26,7 +26,9 @@ export function UsageWindowBar({
|
||||
onTogglePin,
|
||||
pinLabel,
|
||||
pinTestID,
|
||||
retained = false,
|
||||
}: {
|
||||
retained?: boolean;
|
||||
window: UsageWindow;
|
||||
displayAs: UsageDisplayAs;
|
||||
/** Whether the row pins the window to the sidebar. When false the row is a plain bar. */
|
||||
@@ -40,10 +42,13 @@ export function UsageWindowBar({
|
||||
const shownPct = displayPercent(window, displayAs);
|
||||
const tone = windowTone(window);
|
||||
|
||||
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
|
||||
const isAtRisk =
|
||||
window.runsOutAt != null &&
|
||||
window.shortfallPct != null &&
|
||||
(!retained || Date.parse(window.runsOutAt) > Date.now());
|
||||
const trailing = isAtRisk
|
||||
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
|
||||
: formatResetLabel(window.resetsAt);
|
||||
: formatResetLabel(window.resetsAt, retained);
|
||||
|
||||
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
|
||||
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
|
||||
|
||||
@@ -53,4 +53,14 @@ describe("resolveToolCallIconName", () => {
|
||||
|
||||
expect(icon).toBe("wrench");
|
||||
});
|
||||
|
||||
it("uses the Paseo icon for agent messages despite the legacy bot detail icon", () => {
|
||||
const icon = resolveToolCallIconName(
|
||||
"agent_message",
|
||||
{ type: "plain_text", text: "Done.", icon: "bot" },
|
||||
{ event: "finished", sender: { id: "agt_1" }, text: "Done." },
|
||||
);
|
||||
|
||||
expect(icon).toBe("paseo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types";
|
||||
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
|
||||
@@ -17,7 +18,15 @@ const TOOL_DETAIL_ICON_NAMES: Record<ToolCallDetail["type"], ToolCallIcon> = {
|
||||
unknown: "wrench",
|
||||
};
|
||||
|
||||
export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon {
|
||||
export function resolveToolCallIconName(
|
||||
toolName: string,
|
||||
detail?: ToolCallDetail,
|
||||
agentMessage?: AgentMessage,
|
||||
): ToolCallIcon {
|
||||
// Agent messages are Paseo deliveries; the wire detail's legacy "bot" icon is for old clients.
|
||||
if (agentMessage) {
|
||||
return "paseo";
|
||||
}
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
|
||||
if (detail?.type === "plain_text" && detail.icon) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name";
|
||||
@@ -36,6 +37,7 @@ export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconCompon
|
||||
export function resolveToolCallIcon(
|
||||
toolName: string,
|
||||
detail?: ToolCallDetail,
|
||||
agentMessage?: AgentMessage,
|
||||
): ToolCallIconComponent {
|
||||
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail));
|
||||
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail, agentMessage));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const resolvePackageEntry = (packageName: string) => {
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: configDefaults.exclude,
|
||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
@@ -27,13 +27,12 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"e2e/support/helpers/*.test.ts",
|
||||
"native-release-version.test.ts",
|
||||
"app.config.test.ts",
|
||||
"vitest-environment.test.ts",
|
||||
],
|
||||
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||
exclude: [...configDefaults.exclude, "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -277,3 +277,40 @@ describe("usage CLI registration", () => {
|
||||
expect(usage.commands[1]!.helpInformation()).toContain("<report-id>");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retained values with current failures and separates successful time from latest attempt", async () => {
|
||||
const success: UsageReportEntry = { ...reports[0]!, successfulAt: "2026-10-09T12:00:15Z" };
|
||||
const current = await runUsageCommand(
|
||||
options,
|
||||
{ kind: "inspect", id: success.id },
|
||||
host([success]).connect,
|
||||
);
|
||||
expect(render(current)).toContain("Latest attempt: 2026-10-09T12:00:00Z");
|
||||
expect(render(current)).toContain("Last successful: 2026-10-09T12:00:15Z");
|
||||
const currentList = await runUsageCommand(options, { kind: "list" }, host([success]).connect);
|
||||
expect(render(currentList)).toContain("Last successful: 2026-10-09T12:00:15Z");
|
||||
if (success.report.status !== "available") throw new Error("fixture must be available");
|
||||
const retained: UsageReportEntry = {
|
||||
...success,
|
||||
fetchedAt: "2026-10-09T12:06:00Z",
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
successfulAt: undefined,
|
||||
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
|
||||
};
|
||||
const daemon = host([retained]);
|
||||
const listed = await runUsageCommand(options, { kind: "list" }, daemon.connect);
|
||||
expect(render(listed)).toContain("Retained: Five hours: 12.5% used");
|
||||
expect(render(listed)).toContain("Last successful: 2026-10-09T12:00:15Z");
|
||||
expect(render(listed)).toContain("Rate limited");
|
||||
expect(JSON.parse(render(listed, { format: "json" }))).toEqual([retained]);
|
||||
const inspected = await runUsageCommand(
|
||||
options,
|
||||
{ kind: "inspect", id: retained.id },
|
||||
daemon.connect,
|
||||
);
|
||||
expect(render(inspected)).toContain("Latest attempt: 2026-10-09T12:06:00Z");
|
||||
expect(render(inspected)).toContain("Last successful: 2026-10-09T12:00:15Z");
|
||||
expect(render(inspected)).toContain("Retained usage:");
|
||||
expect(render(inspected)).toContain("Five hours: 12.5% used");
|
||||
expect(render(inspected)).toContain("Reason: Rate limited");
|
||||
});
|
||||
|
||||
@@ -44,8 +44,18 @@ function summary(entry: UsageReportEntry): string[] {
|
||||
const lines = [failureText(report)];
|
||||
for (const login of entry.loginErrors ?? [])
|
||||
lines.push(`${login.harness}: ${failureText(login.report)}`);
|
||||
if (entry.lastSuccessful) {
|
||||
lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`);
|
||||
lines.push(
|
||||
...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`),
|
||||
);
|
||||
} else lines.push("No successful update");
|
||||
return lines;
|
||||
}
|
||||
return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`];
|
||||
}
|
||||
|
||||
function availableSummary(report: Extract<UsageReport, { status: "available" }>): string[] {
|
||||
const lines = report.windows.map(windowText);
|
||||
lines.push(...(report.balances ?? []).map(balanceText));
|
||||
return lines.length ? lines : ["-"];
|
||||
@@ -68,7 +78,7 @@ const summarySchema: OutputSchema<SummaryRow> = {
|
||||
{ header: "ACCOUNT", field: "account" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "USAGE", field: "usage" },
|
||||
{ header: "FETCHED", field: "fetched" },
|
||||
{ header: "FETCHED (LATEST ATTEMPT)", field: "fetched" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -79,30 +89,15 @@ function inspect(entry: UsageReportEntry): string {
|
||||
`Source ID: ${entry.sourceId}`,
|
||||
`Account: ${value(entry.account.label)}`,
|
||||
`Status: ${entry.report.status}`,
|
||||
`Fetched: ${value(entry.fetchedAt)}`,
|
||||
`Latest attempt: ${value(entry.fetchedAt)}`,
|
||||
];
|
||||
const { report } = entry;
|
||||
if (report.status === "available") {
|
||||
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
|
||||
if (!report.windows.length) lines.push(" -");
|
||||
for (const window of report.windows) {
|
||||
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
|
||||
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
|
||||
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
|
||||
}
|
||||
lines.push("Balances:");
|
||||
if (!report.balances?.length) lines.push(" -");
|
||||
for (const balance of report.balances ?? []) {
|
||||
lines.push(
|
||||
` ${balanceText(balance)} [${balance.id}]`,
|
||||
` Resets: ${value(balance.resetsAt)}`,
|
||||
);
|
||||
}
|
||||
lines.push("Details:");
|
||||
if (!report.details?.length) lines.push(" -");
|
||||
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
|
||||
} else {
|
||||
lines.push(`Reason: ${failureText(report)}`);
|
||||
const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report;
|
||||
const successfulAt = successfulTime(entry);
|
||||
lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update");
|
||||
if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`);
|
||||
if (report) {
|
||||
if (entry.report.status !== "available") lines.push("Retained usage:");
|
||||
lines.push(...availableDetails(report));
|
||||
}
|
||||
if (entry.loginErrors?.length) {
|
||||
lines.push("Login failures:");
|
||||
@@ -135,3 +130,33 @@ export const usageSchema: OutputSchema<UsageReportEntry> = {
|
||||
return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options);
|
||||
},
|
||||
};
|
||||
|
||||
function availableDetails(report: Extract<UsageReport, { status: "available" }>): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
|
||||
if (!report.windows.length) lines.push(" -");
|
||||
for (const window of report.windows) {
|
||||
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
|
||||
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
|
||||
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
|
||||
}
|
||||
lines.push("Balances:");
|
||||
if (!report.balances?.length) lines.push(" -");
|
||||
for (const balance of report.balances ?? []) {
|
||||
lines.push(
|
||||
` ${balanceText(balance)} [${balance.id}]`,
|
||||
` Resets: ${value(balance.resetsAt)}`,
|
||||
);
|
||||
}
|
||||
lines.push("Details:");
|
||||
if (!report.details?.length) lines.push(" -");
|
||||
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function successfulTime(entry: UsageReportEntry): string | undefined {
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Only available reports from older daemons can use their attempt timestamp as freshness.
|
||||
if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt;
|
||||
return entry.lastSuccessful?.fetchedAt;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -232,17 +232,18 @@ test("scrolled chat does not exclude the workspace titlebar from dragging", asyn
|
||||
}
|
||||
await page.getByRole("button", { name: "Close Find", exact: true }).click();
|
||||
await expect(find).toBeHidden();
|
||||
// Closing Find returns keyboard scrolling to the chat viewport, which emits no window region.
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((scroll) => ({
|
||||
focusReturned: document.activeElement?.contains(scroll),
|
||||
tabindex: document.activeElement?.getAttribute("tabindex"),
|
||||
appRegion: getComputedStyle(document.activeElement!).getPropertyValue(
|
||||
"-webkit-app-region",
|
||||
),
|
||||
})),
|
||||
)
|
||||
.toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" });
|
||||
.poll(() => chat.evaluate((scroll) => document.activeElement === scroll))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => chat.evaluate(inspectChatDragExclusions))
|
||||
.toEqual({
|
||||
contentCrossesHeader: true,
|
||||
contentCrossesTabRow: true,
|
||||
focusScopeRegion: "no-drag",
|
||||
exclusions: 0,
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { AgentMessage } from "./agent-message.js";
|
||||
import type { ToolCallDetail } from "./agent-types.js";
|
||||
|
||||
/** Presentation shared by clients; never used to encode provider prompts. */
|
||||
export function buildAgentMessageDisplay(message: AgentMessage): {
|
||||
displayName: string;
|
||||
detail: ToolCallDetail;
|
||||
} {
|
||||
const sender = message.sender.title ?? message.sender.id;
|
||||
const headings: Record<AgentMessage["event"], string> = {
|
||||
message: `Message from ${sender}`,
|
||||
finished: `${sender} finished`,
|
||||
errored: `${sender} errored`,
|
||||
"permission-required": `${sender} needs permission`,
|
||||
closed: `${sender} closed`,
|
||||
};
|
||||
return {
|
||||
displayName: headings[message.event],
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: `From agent: ${message.sender.id}\n\n${message.text}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6306,14 +6306,15 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [
|
||||
}),
|
||||
z.object({ kind: z.literal("no_quota"), detail: z.string() }),
|
||||
]);
|
||||
const AvailableUsageReportSchema = z.object({
|
||||
status: z.literal("available"),
|
||||
planLabel: z.string().optional(),
|
||||
windows: z.array(ProviderUsageWindowSchema),
|
||||
balances: z.array(ProviderUsageBalanceSchema).optional(),
|
||||
details: z.array(ProviderUsageDetailSchema).optional(),
|
||||
});
|
||||
export const UsageReportSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("available"),
|
||||
planLabel: z.string().optional(),
|
||||
windows: z.array(ProviderUsageWindowSchema),
|
||||
balances: z.array(ProviderUsageBalanceSchema).optional(),
|
||||
details: z.array(ProviderUsageDetailSchema).optional(),
|
||||
}),
|
||||
AvailableUsageReportSchema,
|
||||
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
|
||||
z.object({ status: z.literal("error"), error: z.string() }),
|
||||
]);
|
||||
@@ -6325,6 +6326,11 @@ export const UsageReportEntrySchema = z.object({
|
||||
sourceLabel: z.string(),
|
||||
icon: z.string().optional(),
|
||||
report: UsageReportSchema,
|
||||
// Successful completion time; fetchedAt remains the attempt timestamp for ordering.
|
||||
successfulAt: z.string().optional(),
|
||||
lastSuccessful: z
|
||||
.object({ fetchedAt: z.string(), report: AvailableUsageReportSchema })
|
||||
.optional(),
|
||||
loginErrors: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
UsageReportEntrySchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
@@ -436,3 +437,40 @@ test("usage login errors are additive and older reports still parse", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
|
||||
expect(legacy.parse(newMessage)).toEqual(oldMessage);
|
||||
});
|
||||
|
||||
test("usage entries accept older failures and additive successful snapshots", () => {
|
||||
const oldEntry = {
|
||||
id: "source:account",
|
||||
sourceId: "source",
|
||||
sourceLabel: "Source",
|
||||
account: {},
|
||||
fetchedAt: "2026-10-17T10:06:00Z",
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
};
|
||||
expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry);
|
||||
const retained = {
|
||||
...oldEntry,
|
||||
lastSuccessful: {
|
||||
fetchedAt: "2026-10-17T10:00:00Z",
|
||||
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
|
||||
},
|
||||
};
|
||||
expect(UsageReportEntrySchema.parse(retained)).toEqual(retained);
|
||||
const available = {
|
||||
...oldEntry,
|
||||
report: retained.lastSuccessful.report,
|
||||
successfulAt: "2026-10-17T10:00:15Z",
|
||||
};
|
||||
expect(UsageReportEntrySchema.parse(available)).toEqual(available);
|
||||
expect(
|
||||
UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available),
|
||||
).toEqual({ ...oldEntry, report: available.report });
|
||||
// An older client's entry shape strips the additive snapshot and still sees the failure.
|
||||
expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry);
|
||||
expect(
|
||||
UsageReportEntrySchema.safeParse({
|
||||
...oldEntry,
|
||||
lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AgentMessage } from "../agent-message.js";
|
||||
import type { ToolCallDetail } from "../agent-types.js";
|
||||
import { toSummaryText } from "./summary-text.js";
|
||||
|
||||
export interface AgentMessageDisplay {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail: ToolCallDetail;
|
||||
}
|
||||
|
||||
/** Events read noun-first ("Agent finished"); the sender goes in the summary. */
|
||||
const EVENT_LABELS: Record<AgentMessage["event"], string> = {
|
||||
message: "Message received",
|
||||
finished: "Agent finished",
|
||||
errored: "Agent errored",
|
||||
"permission-required": "Agent needs permission",
|
||||
closed: "Agent closed",
|
||||
};
|
||||
|
||||
/** Presentation shared by clients; never used to encode provider prompts. */
|
||||
export function buildAgentMessageDisplay(message: AgentMessage): AgentMessageDisplay {
|
||||
const summary = toSummaryText(message.sender.title ?? message.sender.id);
|
||||
return {
|
||||
displayName: EVENT_LABELS[message.event],
|
||||
...(summary ? { summary } : {}),
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: `From agent: ${message.sender.id}\n\n${message.text}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* One entry per Paseo MCP tool: the timeline label, the context shown after it,
|
||||
* and how the expanded details lay out the call.
|
||||
*
|
||||
* Labels are fixed text so truncation never hides them. Actions read verb-first
|
||||
* ("Send message"); the context goes in the summary.
|
||||
*/
|
||||
|
||||
type ToolInput = Readonly<Record<string, unknown>>;
|
||||
|
||||
/** `targetAgent` is the target agent's title when known, else its ID. */
|
||||
export type PaseoToolContext = (input: ToolInput, targetAgent: string | undefined) => unknown;
|
||||
|
||||
export interface PaseoToolDetailSpec {
|
||||
promptField?: string;
|
||||
inputOrder?: readonly string[];
|
||||
outputFields?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PaseoToolEntry {
|
||||
label: string;
|
||||
context?: PaseoToolContext;
|
||||
detail?: PaseoToolDetailSpec;
|
||||
}
|
||||
|
||||
const WORKSPACE_FIELDS = [
|
||||
"title",
|
||||
"workspaceId",
|
||||
"projectId",
|
||||
"isolation",
|
||||
"path",
|
||||
"mode",
|
||||
"worktreeSlug",
|
||||
"branchName",
|
||||
"baseBranch",
|
||||
"branch",
|
||||
"prNumber",
|
||||
"forge",
|
||||
] as const;
|
||||
const AGENT_FIELDS = [
|
||||
"title",
|
||||
"agentId",
|
||||
"provider",
|
||||
"workspaceId",
|
||||
"cwd",
|
||||
"sessionMode",
|
||||
"modeId",
|
||||
"background",
|
||||
"notifyOnFinish",
|
||||
"internal",
|
||||
"settings",
|
||||
"labels",
|
||||
] as const;
|
||||
const AUTOMATION_FIELDS = [
|
||||
"name",
|
||||
"id",
|
||||
"cron",
|
||||
"timezone",
|
||||
"provider",
|
||||
"cwd",
|
||||
"isolation",
|
||||
"maxRuns",
|
||||
"expiresIn",
|
||||
"clearExpires",
|
||||
] as const;
|
||||
const BROWSER_FIELDS = [
|
||||
"browserId",
|
||||
"url",
|
||||
"ref",
|
||||
"sourceRef",
|
||||
"targetRef",
|
||||
"value",
|
||||
"text",
|
||||
"key",
|
||||
"button",
|
||||
"doubleClick",
|
||||
"modifiers",
|
||||
"filePaths",
|
||||
"fullPage",
|
||||
"maxEntries",
|
||||
"timeoutMs",
|
||||
"deltaX",
|
||||
"deltaY",
|
||||
"width",
|
||||
"height",
|
||||
"function",
|
||||
] as const;
|
||||
|
||||
const AUTOMATION_OUTPUT = ["id", "status", "nextRunAt", "expiresAt"] as const;
|
||||
|
||||
function field(...keys: string[]): PaseoToolContext {
|
||||
return (input) => keys.map((key) => input[key]).find(isPresent);
|
||||
}
|
||||
|
||||
function targetAgent(): PaseoToolContext {
|
||||
return (_input, agent) => agent;
|
||||
}
|
||||
|
||||
function fieldOrTargetAgent(key: string): PaseoToolContext {
|
||||
return (input, agent) => (isPresent(input[key]) ? input[key] : agent);
|
||||
}
|
||||
|
||||
function workspaceContext(): PaseoToolContext {
|
||||
return (input) =>
|
||||
[input.title, input.branchName, input.branch, baseName(input.path)].find(isPresent);
|
||||
}
|
||||
|
||||
function viewportContext(): PaseoToolContext {
|
||||
return (input) =>
|
||||
typeof input.width === "number" && typeof input.height === "number"
|
||||
? `${input.width}×${input.height}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isPresent(value: unknown): boolean {
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return typeof value === "number";
|
||||
}
|
||||
|
||||
function baseName(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
|
||||
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
|
||||
}
|
||||
|
||||
export const PASEO_TOOL_CATALOG = {
|
||||
// Agents
|
||||
create_agent: {
|
||||
label: "Create agent",
|
||||
context: field("title", "initialPrompt"),
|
||||
detail: {
|
||||
promptField: "initialPrompt",
|
||||
inputOrder: AGENT_FIELDS,
|
||||
outputFields: ["agentId", "status", "currentModeId", "cwd"],
|
||||
},
|
||||
},
|
||||
send_agent_prompt: {
|
||||
label: "Send message",
|
||||
context: field("prompt"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AGENT_FIELDS,
|
||||
outputFields: ["status", "lastMessage", "permission"],
|
||||
},
|
||||
},
|
||||
get_agent_status: {
|
||||
label: "Check agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
get_agent_activity: {
|
||||
label: "Read agent activity",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId", "limit"] },
|
||||
},
|
||||
list_agents: {
|
||||
label: "List agents",
|
||||
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
|
||||
},
|
||||
update_agent: {
|
||||
label: "Update agent",
|
||||
context: fieldOrTargetAgent("name"),
|
||||
detail: { inputOrder: AGENT_FIELDS },
|
||||
},
|
||||
set_agent_mode: {
|
||||
label: "Set agent mode",
|
||||
context: field("modeId"),
|
||||
detail: { inputOrder: ["agentId", "modeId"] },
|
||||
},
|
||||
cancel_agent: {
|
||||
label: "Cancel agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
archive_agent: {
|
||||
label: "Archive agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
|
||||
list_pending_permissions: { label: "List permissions" },
|
||||
respond_to_permission: {
|
||||
label: "Respond to permission",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId", "requestId", "response"] },
|
||||
},
|
||||
|
||||
// Workspaces and scripts
|
||||
create_workspace: {
|
||||
label: "Create workspace",
|
||||
context: workspaceContext(),
|
||||
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
|
||||
},
|
||||
list_workspaces: { label: "List workspaces" },
|
||||
rename_workspace: {
|
||||
label: "Rename workspace",
|
||||
context: field("title"),
|
||||
detail: { inputOrder: ["title", "workspaceId"] },
|
||||
},
|
||||
archive_workspace: {
|
||||
label: "Archive workspace",
|
||||
context: field("workspaceId"),
|
||||
detail: {
|
||||
inputOrder: ["workspaceId"],
|
||||
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
|
||||
},
|
||||
},
|
||||
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
|
||||
start_workspace_script: {
|
||||
label: "Start script",
|
||||
context: field("scriptName"),
|
||||
detail: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
},
|
||||
stop_workspace_script: {
|
||||
label: "Stop script",
|
||||
context: field("scriptName"),
|
||||
detail: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
},
|
||||
|
||||
// Terminals
|
||||
create_terminal: {
|
||||
label: "Create terminal",
|
||||
context: field("name", "command"),
|
||||
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
|
||||
},
|
||||
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
|
||||
capture_terminal: {
|
||||
label: "Read terminal",
|
||||
context: field("terminalId"),
|
||||
detail: { inputOrder: ["terminalId", "lines"] },
|
||||
},
|
||||
send_terminal_keys: {
|
||||
label: "Send keys",
|
||||
context: field("keys"),
|
||||
detail: { inputOrder: ["terminalId", "keys", "literal"] },
|
||||
},
|
||||
kill_terminal: {
|
||||
label: "Kill terminal",
|
||||
context: field("terminalId"),
|
||||
detail: { inputOrder: ["terminalId"] },
|
||||
},
|
||||
|
||||
// Schedules and heartbeats
|
||||
create_schedule: {
|
||||
label: "Create schedule",
|
||||
context: field("name", "cron"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AUTOMATION_FIELDS,
|
||||
outputFields: AUTOMATION_OUTPUT,
|
||||
},
|
||||
},
|
||||
create_heartbeat: {
|
||||
label: "Create heartbeat",
|
||||
context: field("name", "cron"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AUTOMATION_FIELDS,
|
||||
outputFields: AUTOMATION_OUTPUT,
|
||||
},
|
||||
},
|
||||
update_schedule: {
|
||||
label: "Update schedule",
|
||||
context: field("name", "id"),
|
||||
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
|
||||
},
|
||||
list_schedules: { label: "List schedules" },
|
||||
inspect_schedule: {
|
||||
label: "Inspect schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
schedule_logs: {
|
||||
label: "Read schedule logs",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
run_schedule_once: {
|
||||
label: "Run schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
|
||||
resume_schedule: {
|
||||
label: "Resume schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
delete_schedule: {
|
||||
label: "Delete schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
delete_heartbeat: {
|
||||
label: "Delete heartbeat",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
|
||||
// Providers
|
||||
list_providers: { label: "List providers" },
|
||||
list_models: {
|
||||
label: "List models",
|
||||
context: field("provider"),
|
||||
detail: { inputOrder: ["provider"] },
|
||||
},
|
||||
list_profiles: { label: "List profiles" },
|
||||
inspect_provider: {
|
||||
label: "Inspect provider",
|
||||
context: field("provider"),
|
||||
detail: { inputOrder: ["provider", "cwd", "settings"] },
|
||||
},
|
||||
|
||||
// Browser
|
||||
browser_list_tabs: browser("List browser tabs"),
|
||||
browser_new_tab: browser("Open browser tab", field("url")),
|
||||
browser_close_tab: browser("Close browser tab", field("browserId")),
|
||||
browser_navigate: browser("Navigate", field("url")),
|
||||
browser_back: browser("Go back"),
|
||||
browser_forward: browser("Go forward"),
|
||||
browser_reload: browser("Reload page"),
|
||||
browser_snapshot: browser("Snapshot page"),
|
||||
browser_screenshot: browser("Take screenshot"),
|
||||
browser_click: browser("Click element", field("ref")),
|
||||
browser_hover: browser("Hover element", field("ref")),
|
||||
browser_drag: browser("Drag element", field("sourceRef")),
|
||||
browser_fill: browser("Fill field", field("ref")),
|
||||
browser_select: browser("Select option", field("value", "ref")),
|
||||
browser_type: browser("Type text", field("text")),
|
||||
browser_keypress: browser("Press key", field("key")),
|
||||
browser_upload: browser("Upload files", field("filePaths")),
|
||||
browser_wait: browser("Wait for page", field("text", "url")),
|
||||
browser_scroll: browser("Scroll page"),
|
||||
browser_resize: browser("Resize browser", viewportContext()),
|
||||
browser_logs: browser("Read browser logs"),
|
||||
browser_evaluate: browser("Run JavaScript", field("function")),
|
||||
} satisfies Record<string, PaseoToolEntry>;
|
||||
|
||||
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
|
||||
|
||||
export function getPaseoToolEntry(leafName: string): PaseoToolEntry | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(PASEO_TOOL_CATALOG, leafName)
|
||||
? PASEO_TOOL_CATALOG[leafName as PaseoToolName]
|
||||
: undefined;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js";
|
||||
import { buildPaseoToolDetailSections } from "./index.js";
|
||||
|
||||
describe("Paseo tool-call detail presentation", () => {
|
||||
it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])(
|
||||
+3
-157
@@ -1,4 +1,5 @@
|
||||
import { getPaseoToolLeafName } from "./tool-name-normalization.js";
|
||||
import { getPaseoToolLeafName } from "../tool-name-normalization.js";
|
||||
import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js";
|
||||
|
||||
export interface PaseoToolDetailField {
|
||||
label: string;
|
||||
@@ -17,161 +18,6 @@ export type PaseoToolDetailSection =
|
||||
fields: PaseoToolDetailField[];
|
||||
};
|
||||
|
||||
interface ToolDetailSpec {
|
||||
promptField?: string;
|
||||
inputOrder?: readonly string[];
|
||||
outputFields?: readonly string[];
|
||||
}
|
||||
|
||||
const WORKSPACE_FIELDS = [
|
||||
"title",
|
||||
"workspaceId",
|
||||
"projectId",
|
||||
"isolation",
|
||||
"path",
|
||||
"mode",
|
||||
"worktreeSlug",
|
||||
"branchName",
|
||||
"baseBranch",
|
||||
"branch",
|
||||
"prNumber",
|
||||
"forge",
|
||||
] as const;
|
||||
const AGENT_FIELDS = [
|
||||
"title",
|
||||
"agentId",
|
||||
"provider",
|
||||
"workspaceId",
|
||||
"cwd",
|
||||
"sessionMode",
|
||||
"modeId",
|
||||
"background",
|
||||
"notifyOnFinish",
|
||||
"internal",
|
||||
"settings",
|
||||
"labels",
|
||||
] as const;
|
||||
const AUTOMATION_FIELDS = [
|
||||
"name",
|
||||
"id",
|
||||
"cron",
|
||||
"timezone",
|
||||
"provider",
|
||||
"cwd",
|
||||
"isolation",
|
||||
"maxRuns",
|
||||
"expiresIn",
|
||||
"clearExpires",
|
||||
] as const;
|
||||
const BROWSER_FIELDS = [
|
||||
"browserId",
|
||||
"url",
|
||||
"ref",
|
||||
"sourceRef",
|
||||
"targetRef",
|
||||
"value",
|
||||
"text",
|
||||
"key",
|
||||
"button",
|
||||
"doubleClick",
|
||||
"modifiers",
|
||||
"filePaths",
|
||||
"fullPage",
|
||||
"maxEntries",
|
||||
"timeoutMs",
|
||||
"deltaX",
|
||||
"deltaY",
|
||||
"width",
|
||||
"height",
|
||||
"function",
|
||||
] as const;
|
||||
|
||||
const TOOL_SPECS: Readonly<Record<string, ToolDetailSpec>> = {
|
||||
create_workspace: {
|
||||
inputOrder: WORKSPACE_FIELDS,
|
||||
outputFields: ["workspaceId", "projectId"],
|
||||
},
|
||||
list_workspaces: {},
|
||||
archive_workspace: {
|
||||
inputOrder: ["workspaceId"],
|
||||
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
|
||||
},
|
||||
rename_workspace: { inputOrder: ["title", "workspaceId"] },
|
||||
create_agent: {
|
||||
promptField: "initialPrompt",
|
||||
inputOrder: AGENT_FIELDS,
|
||||
outputFields: ["agentId", "status", "currentModeId", "cwd"],
|
||||
},
|
||||
send_agent_prompt: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AGENT_FIELDS,
|
||||
outputFields: ["status", "lastMessage", "permission"],
|
||||
},
|
||||
get_agent_status: { inputOrder: ["agentId"] },
|
||||
list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
|
||||
cancel_agent: { inputOrder: ["agentId"] },
|
||||
archive_agent: { inputOrder: ["agentId"] },
|
||||
kill_agent: { inputOrder: ["agentId"] },
|
||||
update_agent: { inputOrder: AGENT_FIELDS },
|
||||
get_agent_activity: { inputOrder: ["agentId", "limit"] },
|
||||
set_agent_mode: { inputOrder: ["agentId", "modeId"] },
|
||||
list_workspace_scripts: { inputOrder: ["workspaceId"] },
|
||||
start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
list_terminals: { inputOrder: ["cwd", "all"] },
|
||||
create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
|
||||
kill_terminal: { inputOrder: ["terminalId"] },
|
||||
capture_terminal: { inputOrder: ["terminalId", "lines"] },
|
||||
send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] },
|
||||
create_schedule: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AUTOMATION_FIELDS,
|
||||
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
|
||||
},
|
||||
create_heartbeat: {
|
||||
promptField: "prompt",
|
||||
inputOrder: AUTOMATION_FIELDS,
|
||||
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
|
||||
},
|
||||
delete_heartbeat: { inputOrder: ["id"] },
|
||||
list_schedules: {},
|
||||
inspect_schedule: { inputOrder: ["id"] },
|
||||
pause_schedule: { inputOrder: ["id"] },
|
||||
resume_schedule: { inputOrder: ["id"] },
|
||||
delete_schedule: { inputOrder: ["id"] },
|
||||
update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
|
||||
schedule_logs: { inputOrder: ["id"] },
|
||||
run_schedule_once: { inputOrder: ["id"] },
|
||||
list_providers: {},
|
||||
list_models: { inputOrder: ["provider"] },
|
||||
list_profiles: {},
|
||||
inspect_provider: { inputOrder: ["provider", "cwd", "settings"] },
|
||||
list_pending_permissions: {},
|
||||
respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] },
|
||||
browser_list_tabs: {},
|
||||
browser_new_tab: { inputOrder: BROWSER_FIELDS },
|
||||
browser_snapshot: { inputOrder: BROWSER_FIELDS },
|
||||
browser_click: { inputOrder: BROWSER_FIELDS },
|
||||
browser_fill: { inputOrder: BROWSER_FIELDS },
|
||||
browser_wait: { inputOrder: BROWSER_FIELDS },
|
||||
browser_type: { inputOrder: BROWSER_FIELDS },
|
||||
browser_keypress: { inputOrder: BROWSER_FIELDS },
|
||||
browser_navigate: { inputOrder: BROWSER_FIELDS },
|
||||
browser_back: { inputOrder: BROWSER_FIELDS },
|
||||
browser_forward: { inputOrder: BROWSER_FIELDS },
|
||||
browser_reload: { inputOrder: BROWSER_FIELDS },
|
||||
browser_screenshot: { inputOrder: BROWSER_FIELDS },
|
||||
browser_upload: { inputOrder: BROWSER_FIELDS },
|
||||
browser_hover: { inputOrder: BROWSER_FIELDS },
|
||||
browser_select: { inputOrder: BROWSER_FIELDS },
|
||||
browser_drag: { inputOrder: BROWSER_FIELDS },
|
||||
browser_logs: { inputOrder: BROWSER_FIELDS },
|
||||
browser_evaluate: { inputOrder: BROWSER_FIELDS },
|
||||
browser_scroll: { inputOrder: BROWSER_FIELDS },
|
||||
browser_resize: { inputOrder: BROWSER_FIELDS },
|
||||
browser_close_tab: { inputOrder: BROWSER_FIELDS },
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
agentId: "Agent",
|
||||
archivedAgentIds: "Archived agents",
|
||||
@@ -330,7 +176,7 @@ export function buildPaseoToolDetailSections(
|
||||
const leafName = getPaseoToolLeafName(toolName);
|
||||
if (!leafName) return null;
|
||||
|
||||
const spec = TOOL_SPECS[leafName] ?? {};
|
||||
const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {};
|
||||
const sections: PaseoToolDetailSection[] = [];
|
||||
if (spec.promptField && isRecord(input)) {
|
||||
const prompt = input[spec.promptField];
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildAgentMessageDisplay,
|
||||
buildPaseoToolDisplay,
|
||||
getPaseoToolTargetAgentId,
|
||||
MAX_SUMMARY_CHARS,
|
||||
PASEO_TOOL_NAMES,
|
||||
} from "./index.js";
|
||||
|
||||
function display(toolName: string, input: unknown, targetAgentTitle?: string) {
|
||||
return buildPaseoToolDisplay({
|
||||
toolName,
|
||||
detail: { type: "unknown", input, output: null },
|
||||
...(targetAgentTitle ? { targetAgentTitle } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Paseo tool display", () => {
|
||||
it.each([
|
||||
[
|
||||
"send_agent_prompt",
|
||||
{ agentId: "agt_1", prompt: "Fix the failing test" },
|
||||
"Send message",
|
||||
"Fix the failing test",
|
||||
],
|
||||
["create_agent", { title: "Reviewer", initialPrompt: "Review it" }, "Create agent", "Reviewer"],
|
||||
["create_agent", { initialPrompt: "Review it" }, "Create agent", "Review it"],
|
||||
["get_agent_status", { agentId: "agt_1" }, "Check agent", "agt_1"],
|
||||
["update_agent", { agentId: "agt_1", name: "Renamed" }, "Update agent", "Renamed"],
|
||||
["create_workspace", { path: "/tmp/repo/feature-x" }, "Create workspace", "feature-x"],
|
||||
["create_workspace", { title: "Docs", branchName: "docs" }, "Create workspace", "Docs"],
|
||||
["start_workspace_script", { scriptName: "web" }, "Start script", "web"],
|
||||
["send_terminal_keys", { terminalId: "t1", keys: "npm test" }, "Send keys", "npm test"],
|
||||
["create_schedule", { prompt: "Check CI", cron: "0 9 * * 1" }, "Create schedule", "0 9 * * 1"],
|
||||
["browser_navigate", { url: "https://paseo.sh" }, "Navigate", "https://paseo.sh"],
|
||||
[
|
||||
"browser_upload",
|
||||
{ ref: "@e1", filePaths: ["/a.png", "/b.png"] },
|
||||
"Upload files",
|
||||
"/a.png +1",
|
||||
],
|
||||
["browser_resize", { width: 800, height: 600 }, "Resize browser", "800×600"],
|
||||
])("presents %s with a fixed label and its context", (tool, input, label, summary) => {
|
||||
expect(display(`mcp__paseo__${tool}`, input)).toEqual({ displayName: label, summary });
|
||||
});
|
||||
|
||||
it("omits the summary when a call has no context", () => {
|
||||
expect(display("mcp__paseo__list_agents", {})).toEqual({ displayName: "List agents" });
|
||||
});
|
||||
|
||||
it("recognizes every Paseo namespace format", () => {
|
||||
for (const name of ["mcp__paseo__kill_agent", "paseo.kill_agent", "paseo_remote.kill_agent"]) {
|
||||
expect(display(name, { agentId: "agt_1" })?.displayName).toBe("Kill agent");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null outside the Paseo namespace, including speak", () => {
|
||||
expect(display("Bash", {})).toBeNull();
|
||||
expect(display("mcp__paseo__speak", { text: "hi" })).toBeNull();
|
||||
});
|
||||
|
||||
it("humanizes a Paseo tool that has no catalog entry", () => {
|
||||
expect(display("mcp__paseo__brand_new_tool", {})).toEqual({ displayName: "Brand new tool" });
|
||||
});
|
||||
|
||||
it("prefers the known agent title over the agent ID", () => {
|
||||
expect(display("mcp__paseo__cancel_agent", { agentId: "agt_1" }, "Reviewer")).toEqual({
|
||||
displayName: "Cancel agent",
|
||||
summary: "Reviewer",
|
||||
});
|
||||
expect(
|
||||
getPaseoToolTargetAgentId("mcp__paseo__cancel_agent", {
|
||||
type: "unknown",
|
||||
input: { agentId: "agt_1" },
|
||||
output: null,
|
||||
}),
|
||||
).toBe("agt_1");
|
||||
expect(
|
||||
getPaseoToolTargetAgentId("Bash", { type: "unknown", input: { agentId: "x" }, output: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates a massive single-line prompt and collapses whitespace", () => {
|
||||
const prompt = `Line one\n\n line two ${"x".repeat(1_000_000)}`;
|
||||
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
|
||||
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
|
||||
expect(summary?.startsWith("Line one line two xxx")).toBe(true);
|
||||
expect(summary?.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds the scan even when the prompt opens with a wall of whitespace", () => {
|
||||
const prompt = `${" ".repeat(10_000)}hidden`;
|
||||
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
|
||||
expect(summary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a short prompt intact", () => {
|
||||
expect(
|
||||
display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt: " hi\nthere " })?.summary,
|
||||
).toBe("hi there");
|
||||
});
|
||||
|
||||
it("catalogs tool names as lowercase leaf names", () => {
|
||||
for (const name of PASEO_TOOL_NAMES) {
|
||||
expect(name).toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent message display", () => {
|
||||
it.each([
|
||||
["message", "Message received"],
|
||||
["finished", "Agent finished"],
|
||||
["errored", "Agent errored"],
|
||||
["permission-required", "Agent needs permission"],
|
||||
["closed", "Agent closed"],
|
||||
] as const)("labels %s noun-first with the sender as context", (event, label) => {
|
||||
const result = buildAgentMessageDisplay({
|
||||
event,
|
||||
sender: { id: "srv::agt_1", title: "QA messenger" },
|
||||
text: "Done.",
|
||||
});
|
||||
expect(result.displayName).toBe(label);
|
||||
expect(result.summary).toBe("QA messenger");
|
||||
expect(result.detail).toEqual({ type: "plain_text", text: "From agent: srv::agt_1\n\nDone." });
|
||||
});
|
||||
|
||||
it("falls back to the sender ID and truncates a long title", () => {
|
||||
expect(
|
||||
buildAgentMessageDisplay({ event: "finished", sender: { id: "srv::agt_1" }, text: "" })
|
||||
.summary,
|
||||
).toBe("srv::agt_1");
|
||||
const summary = buildAgentMessageDisplay({
|
||||
event: "finished",
|
||||
sender: { id: "a", title: "t".repeat(500) },
|
||||
text: "",
|
||||
}).summary;
|
||||
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ToolCallDetail } from "../agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "../tool-name-normalization.js";
|
||||
import { getPaseoToolEntry, PASEO_TOOL_CATALOG, type PaseoToolName } from "./catalog.js";
|
||||
import { toSummaryText } from "./summary-text.js";
|
||||
|
||||
export { buildAgentMessageDisplay, type AgentMessageDisplay } from "./agent-message.js";
|
||||
export {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailField,
|
||||
type PaseoToolDetailSection,
|
||||
} from "./detail-sections.js";
|
||||
export { MAX_SUMMARY_CHARS } from "./summary-text.js";
|
||||
export type { PaseoToolName };
|
||||
|
||||
export interface PaseoToolDisplayInput {
|
||||
toolName: string;
|
||||
detail: ToolCallDetail;
|
||||
/** Title of the agent named by the call's `agentId`, when the caller knows it. */
|
||||
targetAgentTitle?: string;
|
||||
}
|
||||
|
||||
export interface PaseoToolDisplay {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly PaseoToolName[];
|
||||
|
||||
/** Returns null for tools outside the Paseo namespace. */
|
||||
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
|
||||
const leafName = paseoLeafName(input.toolName);
|
||||
if (!leafName) return null;
|
||||
const entry = getPaseoToolEntry(leafName);
|
||||
if (!entry) return { displayName: humanizeLeafName(leafName) };
|
||||
|
||||
const toolInput = readToolInput(input.detail);
|
||||
const targetAgent = input.targetAgentTitle ?? readString(toolInput.agentId);
|
||||
const summary = formatContext(entry.context?.(toolInput, targetAgent));
|
||||
return { displayName: entry.label, ...(summary ? { summary } : {}) };
|
||||
}
|
||||
|
||||
/** The agent a Paseo call acts on, so callers can resolve its title. */
|
||||
export function getPaseoToolTargetAgentId(
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
): string | null {
|
||||
if (!detail || !paseoLeafName(toolName)) return null;
|
||||
return readString(readToolInput(detail).agentId) ?? null;
|
||||
}
|
||||
|
||||
function paseoLeafName(toolName: string): string | null {
|
||||
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
|
||||
}
|
||||
|
||||
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
|
||||
if (detail.type !== "unknown") return {};
|
||||
const value = detail.input;
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function formatContext(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return toSummaryText(value);
|
||||
if (typeof value === "number") return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
const [first, ...rest] = value;
|
||||
const head = typeof first === "string" ? toSummaryText(first) : undefined;
|
||||
if (!head) return undefined;
|
||||
return rest.length > 0 ? `${head} +${rest.length}` : head;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function humanizeLeafName(leafName: string): string {
|
||||
const sentence = leafName
|
||||
.replace(/[._-]+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user