mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cdc11eaf | ||
|
|
3882b957a6 | ||
|
|
095dd0316e | ||
|
|
a65478d0bf | ||
|
|
fc12843cef | ||
|
|
a5c566f807 | ||
|
|
9521ee9955 | ||
|
|
bd5061e7bd | ||
|
|
df7f1f0b75 | ||
|
|
cf62b00911 | ||
|
|
efd6d95f91 | ||
|
|
1a6a9fa8d0 | ||
|
|
a7a21d91c2 | ||
|
|
b2da58b5bf | ||
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 | ||
|
|
c517d07d6f | ||
|
|
6388c466b1 | ||
|
|
3c48ac3ad2 |
+160
-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,11 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
# Share of the test list per shard, rebalanced from the Playwright step
|
||||
# minutes of five eight-shard runs so the slowest and fastest shards meet.
|
||||
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -420,56 +467,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -19,6 +20,10 @@ 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";
|
||||
@@ -120,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) => {
|
||||
@@ -174,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;
|
||||
});
|
||||
@@ -219,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,
|
||||
}) => {
|
||||
|
||||
@@ -45,7 +45,8 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
|
||||
@@ -71,6 +71,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await closeSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent's row shows the facts its sheet shows", async () => {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
|
||||
interface DragMeasurement {
|
||||
observer: MutationObserver;
|
||||
stylesheetMutations: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForResizePaint(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function beginResizeMeasurement(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const stylesheet = document.getElementById("unistyles-web");
|
||||
if (!stylesheet) throw new Error("Shared stylesheet is missing");
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
|
||||
const measurement: DragMeasurement = {
|
||||
stylesheetMutations: 0,
|
||||
observer: new MutationObserver((records) => {
|
||||
measurement.stylesheetMutations += records.length;
|
||||
}),
|
||||
};
|
||||
measurement.observer.observe(stylesheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
|
||||
});
|
||||
}
|
||||
|
||||
async function previewExplorerResize(page: Page, distance: number) {
|
||||
const handle = page
|
||||
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||
.getByRole("separator");
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
const bounds = await handle.boundingBox();
|
||||
if (!bounds) throw new Error("Explorer resize handle is missing");
|
||||
const before = (await explorer.boundingBox())!.width;
|
||||
const x = bounds.x + bounds.width / 2;
|
||||
const y = bounds.y + 100;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await waitForResizePaint(page);
|
||||
await beginResizeMeasurement(page);
|
||||
for (let move = 1; move <= 20; move++) {
|
||||
await page.mouse.move(x + (distance * move) / 20, y);
|
||||
await waitForResizePaint(page);
|
||||
}
|
||||
const during = (await explorer.boundingBox())!.width;
|
||||
const measurement = await page.evaluate(() => {
|
||||
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
|
||||
state.observer.disconnect();
|
||||
return {
|
||||
stylesheetMutations: state.stylesheetMutations,
|
||||
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
|
||||
.filter(([id]) => id.startsWith("SplitPaneView:"))
|
||||
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
|
||||
};
|
||||
});
|
||||
await page.mouse.up();
|
||||
await waitForResizePaint(page);
|
||||
const after = (await explorer.boundingBox())!.width;
|
||||
expect(during).toBeCloseTo(before - distance, 0);
|
||||
expect(after).toBeCloseTo(during, 0);
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(explorer).toBeVisible();
|
||||
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
|
||||
}
|
||||
|
||||
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
const workspace = await withWorkspace();
|
||||
await workspace.navigateTo();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
|
||||
id.startsWith("SplitPaneView:"),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
const grown = await previewExplorerResize(page, -100);
|
||||
const shrunk = await previewExplorerResize(page, 50);
|
||||
expect(grown.stylesheetMutations).toBe(0);
|
||||
expect(grown.paneRenders).toBe(0);
|
||||
expect(shrunk.stylesheetMutations).toBe(0);
|
||||
expect(shrunk.paneRenders).toBe(0);
|
||||
await expectExplorerWidthSurvivesReload(page, shrunk.width);
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -203,7 +203,8 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(lineBox).not.toBeNull();
|
||||
expect(filenameBox).not.toBeNull();
|
||||
expect(pathBox).not.toBeNull();
|
||||
expect(rowBox?.height).toBe(36);
|
||||
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
|
||||
expect(rowBox?.height).toBe(32);
|
||||
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,10 @@ import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
@@ -29,8 +32,11 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { createProjectIconTarget } from "@/projects/icon-target";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
@@ -49,8 +55,14 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import {
|
||||
COMMAND_CENTER_LINE_HEIGHT,
|
||||
COMMAND_CENTER_LIST_METRICS,
|
||||
COMMAND_CENTER_SECTION_GAP,
|
||||
type CommandCenterListMetrics,
|
||||
} from "./list-metrics";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import {
|
||||
@@ -89,42 +101,14 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||
const leftRunning = left.status === "running" ? 1 : 0;
|
||||
const rightRunning = right.status === "running" ? 1 : 0;
|
||||
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function compareWorkspacesByTitle(
|
||||
left: CommandCenterWorkspaceResult,
|
||||
right: CommandCenterWorkspaceResult,
|
||||
): number {
|
||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
||||
}
|
||||
|
||||
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
|
||||
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
|
||||
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
|
||||
* collation sort over every workspace.
|
||||
*
|
||||
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
|
||||
* now refresh when agents or projects change rather than on every keystroke.
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
*/
|
||||
function useBuiltInRows(open: boolean): {
|
||||
workspaces: CommandCenterWorkspaceResult[];
|
||||
@@ -134,11 +118,32 @@ function useBuiltInRows(open: boolean): {
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
open
|
||||
? projects.flatMap((project) =>
|
||||
project.hosts.flatMap((host) => {
|
||||
const target = createProjectIconTarget({
|
||||
projectViewKey: project.viewKey,
|
||||
placement: { ...host, iconWorkingDir: host.repoRoot },
|
||||
});
|
||||
return target ? [target] : [];
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
[open, projects],
|
||||
);
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return { workspaces: [], agents: [] };
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
viewKey: project.viewKey,
|
||||
name: project.projectName,
|
||||
iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null,
|
||||
};
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
@@ -151,7 +156,9 @@ function useBuiltInRows(open: boolean): {
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
@@ -160,34 +167,31 @@ function useBuiltInRows(open: boolean): {
|
||||
}
|
||||
}
|
||||
}
|
||||
allWorkspaces.sort(compareWorkspacesByTitle);
|
||||
const workspaceTitleByKey = new Map(
|
||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||
);
|
||||
const agentRows = agents
|
||||
.map<CommandCenterAgentResult>((agent) => {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
formatTimeAgo(agent.lastActivityAt),
|
||||
]),
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
});
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, open, projects, showHost, t]);
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
}
|
||||
|
||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||
@@ -202,16 +206,14 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 3,
|
||||
title: t("shell.commandCenter.agents"),
|
||||
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
|
||||
sortAgents(left.agent, right.agent),
|
||||
),
|
||||
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
|
||||
},
|
||||
];
|
||||
}, [open, query, rows, t]);
|
||||
@@ -236,7 +238,7 @@ interface CommandCenterState {
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(): CommandCenterState {
|
||||
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
@@ -290,8 +292,9 @@ function useCommandCenterState(): CommandCenterState {
|
||||
scope === "files"
|
||||
? fileSections
|
||||
: [...contributionSections, ...fileSections, ...builtInSections],
|
||||
listMetrics,
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, scope],
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
@@ -381,11 +384,19 @@ function useCommandCenterState(): CommandCenterState {
|
||||
|
||||
interface ResultRowProps {
|
||||
result: CommandCenterResult;
|
||||
height: number;
|
||||
query: string;
|
||||
active: boolean;
|
||||
onSelect(result: CommandCenterResult): void;
|
||||
}
|
||||
|
||||
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
|
||||
const ResultRow = memo(function ResultRow({
|
||||
result,
|
||||
height,
|
||||
query,
|
||||
active,
|
||||
onSelect,
|
||||
}: ResultRowProps) {
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
@@ -402,15 +413,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(result.kind === "agent" ||
|
||||
result.kind === "workspace" ||
|
||||
(result.kind === "contribution" &&
|
||||
result.contribution.presentation.kind === "action" &&
|
||||
Boolean(result.contribution.presentation.subtitle))) &&
|
||||
styles.tallRow,
|
||||
{ height },
|
||||
(Boolean(hovered) || pressed || active) && styles.activeRow,
|
||||
],
|
||||
[active, result],
|
||||
[active, height],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
@@ -424,12 +430,75 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} />
|
||||
<ResultContent result={result} query={query} />
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
/**
|
||||
* Text with the query's terms marked. Marks use the same matcher options the rows were ranked
|
||||
* with, so a mark always points at something that made the row match.
|
||||
*/
|
||||
function QueryHighlightedText({
|
||||
query,
|
||||
text,
|
||||
...props
|
||||
}: { query: string } & Omit<HighlightedTextProps, "ranges">) {
|
||||
const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]);
|
||||
return <HighlightedText text={text} ranges={ranges} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch
|
||||
* truncates without pushing the time out of view, and so the time is never part of what the
|
||||
* query matches against. It keeps itself current while the command center stays open.
|
||||
*/
|
||||
function SubtitleWithTimestamp({
|
||||
query,
|
||||
subtitle,
|
||||
activityAt,
|
||||
testID,
|
||||
}: {
|
||||
query: string;
|
||||
subtitle: string;
|
||||
activityAt: Date | null;
|
||||
testID: string;
|
||||
}) {
|
||||
const timeAgo = useTimeAgo(activityAt);
|
||||
return (
|
||||
<View style={styles.subtitleLine}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={subtitle}
|
||||
style={[styles.subtitle, styles.subtitleText]}
|
||||
numberOfLines={1}
|
||||
testID={testID}
|
||||
/>
|
||||
{timeAgo ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-result-timestamp">
|
||||
{/* Non-breaking: web collapses a leading plain space in its own flex item. */}
|
||||
{subtitle ? `\u00a0· ${timeAgo}` : timeAgo}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
@@ -438,13 +507,16 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
<MaterialFileIcon fileName={result.title} size={16} />
|
||||
</View>
|
||||
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
|
||||
<Text style={styles.fileName} testID="command-center-file-name">
|
||||
{result.title}
|
||||
</Text>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.fileName}
|
||||
testID="command-center-file-name"
|
||||
/>
|
||||
{result.subtitle ? (
|
||||
<Text style={styles.filePath} testID="command-center-file-path">
|
||||
{" "}
|
||||
{result.subtitle}
|
||||
<QueryHighlightedText query={query} text={result.subtitle} />
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
@@ -465,12 +537,18 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -481,20 +559,28 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<ThemedFolder size={16} strokeWidth={2.2} />
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View
|
||||
style={styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.subtitle}
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-workspace-subtitle"
|
||||
>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -512,11 +598,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{presentation.title}
|
||||
</Text>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={presentation.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{presentation.subtitle ? (
|
||||
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={presentation.subtitle}
|
||||
style={styles.subtitle}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -541,14 +635,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
style={styles.breadcrumbPart}
|
||||
>
|
||||
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
|
||||
<Text
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={part}
|
||||
style={
|
||||
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -563,11 +657,9 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
}
|
||||
|
||||
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
|
||||
let sizeStyle = styles.dividerSection;
|
||||
if (row.title && row.divider) sizeStyle = styles.dividedSection;
|
||||
if (row.title && !row.divider) sizeStyle = styles.titledSection;
|
||||
const style = useMemo(() => [styles.section, { height: row.height }], [row.height]);
|
||||
return (
|
||||
<View style={sizeStyle}>
|
||||
<View style={style}>
|
||||
{row.divider ? <View style={styles.sectionDivider} /> : null}
|
||||
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
|
||||
</View>
|
||||
@@ -576,8 +668,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const state = useCommandCenterState();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
@@ -644,11 +738,13 @@ export function CommandCenter() {
|
||||
) : (
|
||||
<ResultRow
|
||||
result={item.result}
|
||||
height={item.height}
|
||||
query={state.query}
|
||||
active={item.result.id === state.activeId}
|
||||
onSelect={state.select}
|
||||
/>
|
||||
),
|
||||
[state.activeId, state.select],
|
||||
[state.activeId, state.query, state.select],
|
||||
);
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
|
||||
@@ -901,20 +997,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: "500",
|
||||
},
|
||||
results: { flex: 1 },
|
||||
// Sections and rows take their height from list-metrics; these styles only place content in it.
|
||||
section: { justifyContent: "flex-end" },
|
||||
sectionLabel: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
paddingBottom: COMMAND_CENTER_SECTION_GAP,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionDivider: {
|
||||
height: 1,
|
||||
marginTop: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
marginVertical: COMMAND_CENTER_SECTION_GAP,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
|
||||
tallRow: { height: 56 },
|
||||
row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" },
|
||||
activeRow: { backgroundColor: theme.colors.surface1 },
|
||||
rowContent: {
|
||||
flexDirection: "row",
|
||||
@@ -933,14 +1030,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 18,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.title,
|
||||
flexShrink: 1,
|
||||
},
|
||||
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 },
|
||||
fileLine: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
},
|
||||
fileName: { color: theme.colors.foreground },
|
||||
filePath: { color: theme.colors.foregroundMuted },
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 },
|
||||
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle,
|
||||
},
|
||||
subtitleLine: { flexDirection: "row", minWidth: 0 },
|
||||
subtitleText: { flexShrink: 1, minWidth: 0 },
|
||||
iconSlot: {
|
||||
width: 16,
|
||||
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: { fontSize: 9 },
|
||||
rowShortcut: { flexShrink: 0 },
|
||||
breadcrumb: {
|
||||
flex: 1,
|
||||
@@ -958,7 +1072,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
breadcrumbGroup: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
flexShrink: 0,
|
||||
},
|
||||
emptyText: {
|
||||
@@ -978,7 +1092,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetBackground: { backgroundColor: theme.colors.surface0 },
|
||||
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
titledSection: { height: 32, justifyContent: "flex-end" },
|
||||
dividedSection: { height: 49, justifyContent: "flex-end" },
|
||||
dividerSection: { height: 17, justifyContent: "flex-end" },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* Every vertical size in the Command Center list, in one place.
|
||||
*
|
||||
* The list is virtualized, so it needs each row's height before rendering it. Rather than keep
|
||||
* those numbers in sync with the styles by hand, rows render at exactly the height computed here
|
||||
* and center their content. The styles read the line heights below, so this arithmetic is the
|
||||
* layout.
|
||||
*
|
||||
* A layout chooses one thing: the inset above and below a row's text. Both layouts share the same
|
||||
* shape, so nothing downstream asks which one it got.
|
||||
*/
|
||||
export const COMMAND_CENTER_LINE_HEIGHT = {
|
||||
/** A row with no subtitle. Also the icon slot, so the icon centers on the title. */
|
||||
singleLine: 20,
|
||||
title: 18,
|
||||
subtitle: 16,
|
||||
sectionLabel: 16,
|
||||
} as const;
|
||||
|
||||
/** Space around a section's label and around the rule between sections. */
|
||||
export const COMMAND_CENTER_SECTION_GAP = SPACING[2];
|
||||
const SECTION_DIVIDER_THICKNESS = 1;
|
||||
|
||||
export interface CommandCenterListMetrics {
|
||||
singleLineRow: number;
|
||||
twoLineRow: number;
|
||||
/** A section label and the gaps around it. */
|
||||
sectionTitle: number;
|
||||
/** The rule between two sections and the gaps around it. */
|
||||
sectionDivider: number;
|
||||
}
|
||||
|
||||
function listMetrics(rowInset: number): CommandCenterListMetrics {
|
||||
const line = COMMAND_CENTER_LINE_HEIGHT;
|
||||
return {
|
||||
singleLineRow: rowInset + line.singleLine + rowInset,
|
||||
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
|
||||
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
|
||||
sectionDivider:
|
||||
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that
|
||||
* `density.rowHeight` uses on compact layouts.
|
||||
*/
|
||||
export const COMMAND_CENTER_LIST_METRICS = {
|
||||
desktop: listMetrics(SPACING[1.5]),
|
||||
compact: listMetrics(SPACING[3]),
|
||||
} as const satisfies Record<string, CommandCenterListMetrics>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
@@ -101,17 +102,28 @@ function autoModeChoice(): CommandCenterContribution {
|
||||
});
|
||||
}
|
||||
|
||||
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
|
||||
function workspace(
|
||||
id: string,
|
||||
title = id,
|
||||
subtitle = "host",
|
||||
activityAt: Date | null = null,
|
||||
): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: null,
|
||||
activityAt,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): Date {
|
||||
return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000);
|
||||
}
|
||||
|
||||
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const section of sections) {
|
||||
@@ -120,6 +132,8 @@ function sectionResultIds(sections: ReturnType<typeof buildContributionSections>
|
||||
return ids;
|
||||
}
|
||||
|
||||
const metrics = COMMAND_CENTER_LIST_METRICS.desktop;
|
||||
|
||||
describe("Command Center result projection", () => {
|
||||
it("query-gates model choices and creates one flat row index", () => {
|
||||
const contributions = [
|
||||
@@ -130,16 +144,19 @@ describe("Command Center result projection", () => {
|
||||
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
|
||||
|
||||
const sections = buildContributionSections(contributions, "o");
|
||||
const projection = projectCommandCenterRows([
|
||||
...sections,
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("workspace:1")],
|
||||
},
|
||||
]);
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
...sections,
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("workspace:1")],
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
expect(projection.rows.map((row) => row.key)).toEqual([
|
||||
"section:models",
|
||||
"opus",
|
||||
@@ -147,7 +164,12 @@ describe("Command Center result projection", () => {
|
||||
"workspace:1",
|
||||
]);
|
||||
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
|
||||
expect(projection.offsets).toEqual([0, 32, 68, 117]);
|
||||
expect(projection.offsets).toEqual([
|
||||
0,
|
||||
metrics.sectionTitle,
|
||||
metrics.sectionTitle + metrics.singleLineRow,
|
||||
metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle,
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows query-only actions only when their group matches a search", () => {
|
||||
@@ -177,15 +199,18 @@ describe("Command Center result projection", () => {
|
||||
|
||||
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
||||
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
||||
const projection = projectCommandCenterRows([
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: "Workspaces",
|
||||
results: workspaces,
|
||||
},
|
||||
]);
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: "Workspaces",
|
||||
results: workspaces,
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
|
||||
let activeId: string | null = workspaces[0].id;
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
@@ -195,7 +220,7 @@ describe("Command Center result projection", () => {
|
||||
const targetId = workspaces[150].id;
|
||||
expect(activeId).toBe(targetId);
|
||||
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
|
||||
expect(projection.offsets[151]).toBe(32 + 150 * 56);
|
||||
expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,7 +273,7 @@ describe("Command Center relevance ordering", () => {
|
||||
schedulesAction(),
|
||||
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
|
||||
];
|
||||
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
|
||||
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics);
|
||||
|
||||
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||
@@ -301,16 +326,19 @@ describe("Command Center relevance ordering", () => {
|
||||
});
|
||||
|
||||
it("keeps Workspaces below contributions even on a perfect match", () => {
|
||||
const projection = projectCommandCenterRows([
|
||||
...buildContributionSections([schedulesAction()], "auto"),
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
},
|
||||
]);
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
...buildContributionSections([schedulesAction()], "auto"),
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
|
||||
});
|
||||
@@ -324,7 +352,7 @@ describe("Command Center relevance ordering", () => {
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
};
|
||||
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
|
||||
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics);
|
||||
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||
"mode:auto",
|
||||
@@ -461,15 +489,19 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
visible: [row.title, row.subtitle],
|
||||
hidden: [],
|
||||
});
|
||||
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
|
||||
left.title.localeCompare(right.title);
|
||||
|
||||
it("caps an empty query and preserves the caller's order", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
|
||||
it("shows the five most recent rows for an empty query", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) =>
|
||||
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
|
||||
);
|
||||
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
|
||||
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"w7",
|
||||
"w6",
|
||||
"w5",
|
||||
"w4",
|
||||
"w3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts the best match first even when it sorts last alphabetically", () => {
|
||||
@@ -480,9 +512,9 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
];
|
||||
|
||||
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["c"]);
|
||||
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks a title match above a match that only the hidden field reached", () => {
|
||||
@@ -492,24 +524,41 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
? { visible: [row.title], hidden: ["deploy"] }
|
||||
: { visible: [row.title], hidden: [] };
|
||||
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
|
||||
).toEqual(["title-hit", "hidden-hit"]);
|
||||
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
|
||||
"title-hit",
|
||||
"hidden-hit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
|
||||
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
|
||||
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
|
||||
it("orders rows in the same match tier by recency, not by where the word sits", () => {
|
||||
// Both are whole-word matches for "usage"; the older one finds it earlier in the title.
|
||||
const rows = [
|
||||
workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)),
|
||||
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
|
||||
];
|
||||
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["first", "second"]);
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a better match tier above a more recent weaker match", () => {
|
||||
const rows = [
|
||||
workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)),
|
||||
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"whole-word",
|
||||
"subsequence",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a row when one token matches nothing", () => {
|
||||
const rows = [workspace("a", "Label as Design")];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
scoreTextFields,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import type { CommandCenterListMetrics } from "./list-metrics";
|
||||
|
||||
/** Sections that reorder by how well they match the query. */
|
||||
export const CONTRIBUTION_SECTION_BAND = 0;
|
||||
@@ -43,13 +44,21 @@ export function compareCommandCenterScores(
|
||||
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
|
||||
}
|
||||
|
||||
export interface CommandCenterWorkspaceProject {
|
||||
viewKey: string;
|
||||
name: string;
|
||||
iconDataUri: string | null;
|
||||
}
|
||||
|
||||
export interface CommandCenterWorkspaceResult {
|
||||
kind: "workspace";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
project: CommandCenterWorkspaceProject;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
activityAt: Date | null;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
@@ -59,6 +68,7 @@ export interface CommandCenterAgentResult {
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
activityAt: Date | null;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
@@ -169,13 +179,12 @@ function contributionSearchFields(
|
||||
return { visible, hidden: contribution.keywords };
|
||||
}
|
||||
|
||||
function resultHeight(result: CommandCenterResult): number {
|
||||
if (result.kind === "workspace" || result.kind === "agent") return 56;
|
||||
if (result.kind === "file") return 36;
|
||||
if (result.contribution.presentation.kind === "action") {
|
||||
return result.contribution.presentation.subtitle ? 56 : 36;
|
||||
}
|
||||
return 36;
|
||||
/** Whether the row draws a second, subtitle line under its title. */
|
||||
function hasSubtitleLine(result: CommandCenterResult): boolean {
|
||||
if (result.kind === "workspace" || result.kind === "agent") return true;
|
||||
if (result.kind === "file") return false;
|
||||
const presentation = result.contribution.presentation;
|
||||
return presentation.kind === "action" && Boolean(presentation.subtitle);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,22 +212,42 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
|
||||
/** Rows shown when nothing has been typed, per pinned category. */
|
||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||
|
||||
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
|
||||
export interface CommandCenterRecentRow {
|
||||
activityAt: Date | null;
|
||||
}
|
||||
|
||||
/** Most recent first. A row with no timestamp sorts after every row that has one. */
|
||||
export function compareByRecency(left: CommandCenterRecentRow, right: CommandCenterRecentRow) {
|
||||
return (right.activityAt?.getTime() ?? 0) - (left.activityAt?.getTime() ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank built-in rows by the kind of match they made: which fields matched, then the matcher's
|
||||
* tier. Offset and spread are deliberately left out. They only say where in the title the word
|
||||
* happened to sit, so "usage" in "Deliver sidebar usage" and in "expose usage data" would be
|
||||
* ordered by sentence position. Leaving them out makes those rows equal, and recency decides.
|
||||
*/
|
||||
export function compareBuiltInScores(left: CommandCenterScore, right: CommandCenterScore): number {
|
||||
return left.fieldRank - right.fieldRank || left.match.tier - right.match.tier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
|
||||
* navigation callbacks and the project tree, none of which matching cares about, so that stays
|
||||
* with the caller and this stays testable without mounting React.
|
||||
*
|
||||
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
|
||||
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
|
||||
* that matched exactly as well.
|
||||
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
|
||||
* query, go most recent first.
|
||||
*/
|
||||
export function filterAndRankBuiltInResults<Row>(
|
||||
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
|
||||
rows: readonly Row[],
|
||||
query: string,
|
||||
fieldsOf: (row: Row) => CommandCenterSearchFields,
|
||||
tiebreak: (left: Row, right: Row) => number,
|
||||
): Row[] {
|
||||
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
||||
if (!query.trim()) {
|
||||
return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
||||
}
|
||||
|
||||
const scored: { row: Row; score: CommandCenterScore }[] = [];
|
||||
for (const row of rows) {
|
||||
@@ -227,7 +256,7 @@ export function filterAndRankBuiltInResults<Row>(
|
||||
}
|
||||
scored.sort(
|
||||
(left, right) =>
|
||||
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
|
||||
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
|
||||
);
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
@@ -293,6 +322,7 @@ export function buildContributionSections(
|
||||
|
||||
export function projectCommandCenterRows(
|
||||
sections: readonly CommandCenterResultSection[],
|
||||
metrics: CommandCenterListMetrics,
|
||||
): CommandCenterListProjection {
|
||||
const populated = sections
|
||||
.filter((section) => section.results.length > 0)
|
||||
@@ -305,10 +335,8 @@ export function projectCommandCenterRows(
|
||||
|
||||
for (const [sectionIndex, section] of populated.entries()) {
|
||||
const divider = sectionIndex > 0;
|
||||
let sectionHeight = 0;
|
||||
if (section.title && divider) sectionHeight = 49;
|
||||
if (section.title && !divider) sectionHeight = 32;
|
||||
if (!section.title && divider) sectionHeight = 17;
|
||||
const sectionHeight =
|
||||
(divider ? metrics.sectionDivider : 0) + (section.title ? metrics.sectionTitle : 0);
|
||||
if (sectionHeight > 0) {
|
||||
offsets.push(offset);
|
||||
rows.push({
|
||||
@@ -321,7 +349,7 @@ export function projectCommandCenterRows(
|
||||
offset += sectionHeight;
|
||||
}
|
||||
for (const result of section.results) {
|
||||
const height = resultHeight(result);
|
||||
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
|
||||
offsets.push(offset);
|
||||
rowIndexByResultId.set(result.id, rows.length);
|
||||
rows.push({ kind: "result", key: result.id, result, height });
|
||||
|
||||
@@ -7,21 +7,22 @@ function workspace(input: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
changeRequestNumber?: number | null;
|
||||
activityAt?: Date | null;
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: input.changeRequestNumber ?? null,
|
||||
activityAt: input.activityAt ?? null,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
|
||||
(row) => row.id,
|
||||
);
|
||||
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("filterAndRankWorkspaces", () => {
|
||||
@@ -79,4 +80,18 @@ describe("filterAndRankWorkspaces", () => {
|
||||
const text = workspace({ id: "a-text", title: "Migration 42" });
|
||||
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
|
||||
});
|
||||
|
||||
it("orders workspaces in the same match tier by recency", () => {
|
||||
const older = workspace({
|
||||
id: "older",
|
||||
title: "Usage hover card",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
});
|
||||
const newer = workspace({
|
||||
id: "newer",
|
||||
title: "Deliver sidebar usage",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
});
|
||||
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
compareCommandCenterScores,
|
||||
compareBuiltInScores,
|
||||
compareByRecency,
|
||||
filterAndRankBuiltInResults,
|
||||
scoreSearchFields,
|
||||
type CommandCenterScore,
|
||||
@@ -33,29 +34,22 @@ function searchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
function compareScoredWorkspaces(
|
||||
left: ScoredWorkspace,
|
||||
right: ScoredWorkspace,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): number {
|
||||
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
|
||||
if (left.changeRequestHit !== right.changeRequestHit) {
|
||||
return left.changeRequestHit ? -1 : 1;
|
||||
}
|
||||
if (left.score && right.score) {
|
||||
const scoreDelta = compareCommandCenterScores(left.score, right.score);
|
||||
const scoreDelta = compareBuiltInScores(left.score, right.score);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
}
|
||||
return tiebreak(left.workspace, right.workspace);
|
||||
return compareByRecency(left.workspace, right.workspace);
|
||||
}
|
||||
|
||||
export function filterAndRankWorkspaces(
|
||||
workspaces: readonly CommandCenterWorkspaceResult[],
|
||||
query: string,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): CommandCenterWorkspaceResult[] {
|
||||
if (!query.trim()) {
|
||||
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
|
||||
}
|
||||
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
@@ -65,6 +59,6 @@ export function filterAndRankWorkspaces(
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
|
||||
}
|
||||
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
|
||||
matches.sort(compareScoredWorkspaces);
|
||||
return matches.map((match) => match.workspace);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ const DATE_SECTION_ORDER = [
|
||||
"older",
|
||||
] as const satisfies readonly DateSectionKey[];
|
||||
|
||||
const HISTORY_MATCH_OPTIONS = { typoTolerant: true } as const;
|
||||
|
||||
type FlatListItem =
|
||||
| { type: "header"; key: string; section: DateSectionKey }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
@@ -190,10 +192,11 @@ function SessionRow({
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const ranges = useMemo(
|
||||
() => ({
|
||||
workspace: findHighlightRanges(search ?? "", workspaceName),
|
||||
title: findHighlightRanges(search ?? "", agent.title ?? ""),
|
||||
branch: findHighlightRanges(search ?? "", branch),
|
||||
project: findHighlightRanges(search ?? "", projectName),
|
||||
// History search matches typo-tolerantly on the daemon, so the marks do too.
|
||||
workspace: findHighlightRanges(search ?? "", workspaceName, HISTORY_MATCH_OPTIONS),
|
||||
title: findHighlightRanges(search ?? "", agent.title ?? "", HISTORY_MATCH_OPTIONS),
|
||||
branch: findHighlightRanges(search ?? "", branch, HISTORY_MATCH_OPTIONS),
|
||||
project: findHighlightRanges(search ?? "", projectName, HISTORY_MATCH_OPTIONS),
|
||||
}),
|
||||
[search, workspaceName, agent.title, branch, projectName],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -88,7 +89,8 @@ import {
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
|
||||
@@ -435,7 +437,7 @@ export function SplitContainer({
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const explorerSidebarDockStyle = useMemo(
|
||||
() => [styles.explorerSidebarDock, { width: explorerSidebarWidth }],
|
||||
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
|
||||
[explorerSidebarWidth],
|
||||
);
|
||||
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
@@ -1121,7 +1123,7 @@ function SplitNodeView({
|
||||
);
|
||||
}
|
||||
|
||||
function SplitPaneView({
|
||||
const SplitPaneView = memo(function SplitPaneView({
|
||||
pane,
|
||||
uiTabs,
|
||||
isFocused,
|
||||
@@ -1158,6 +1160,7 @@ function SplitPaneView({
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
}: SplitPaneViewProps) {
|
||||
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const stableOnFocusPane = useStableEvent(onFocusPane);
|
||||
const paneState = useMemo(
|
||||
@@ -1312,7 +1315,7 @@ function SplitPaneView({
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -63,7 +63,9 @@ describe("findHighlightRanges", () => {
|
||||
);
|
||||
});
|
||||
it("uses the search matcher for typo and scattered-character matches", () => {
|
||||
expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]");
|
||||
expect(
|
||||
marked("billing", findHighlightRanges("bulling", "billing", { typoTolerant: true })),
|
||||
).toBe("[billing]");
|
||||
expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe(
|
||||
"[pas]eo-[bab]ysit",
|
||||
);
|
||||
@@ -79,6 +81,9 @@ describe("findHighlightRanges", () => {
|
||||
"check [conf]i[g]uration",
|
||||
);
|
||||
});
|
||||
it("marks typos only when the list was matched typo-tolerantly", () => {
|
||||
expect(findHighlightRanges("bulling", "billing")).toEqual([]);
|
||||
});
|
||||
it("merges overlapping tokens and ignores empty or unmatched queries", () => {
|
||||
expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]");
|
||||
expect(findHighlightRanges("", "billing")).toEqual([]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
scoreMatch,
|
||||
tokenizeQuery,
|
||||
type MatchRange,
|
||||
type TextFieldsOptions,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
export interface HighlightSegment {
|
||||
@@ -40,11 +41,22 @@ export function toHighlightSegments(
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Best-effort presentation of each query token in this field, independently of other fields. */
|
||||
export function findHighlightRanges(query: string, text: string): MatchRange[] {
|
||||
/**
|
||||
* Best-effort presentation of each query token in this field, independently of other fields.
|
||||
* Pass the same options the list was matched with, so a mark never claims a match the list did
|
||||
* not make.
|
||||
*/
|
||||
export function findHighlightRanges(
|
||||
query: string,
|
||||
text: string,
|
||||
options: TextFieldsOptions = {},
|
||||
): MatchRange[] {
|
||||
const ranges = tokenizeQuery(query)
|
||||
.flatMap((token) => {
|
||||
const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) });
|
||||
const score = scoreMatch(token, text, {
|
||||
fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null,
|
||||
subsequence: options.subsequence,
|
||||
});
|
||||
return score ? matchRanges(token, text, score) : [];
|
||||
})
|
||||
.sort((left, right) => left.start - right.start);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { formatAgentModeLabel } from "@/agent-controls/labels";
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface AgentFactsInput {
|
||||
models: AgentModelDefinition[] | null;
|
||||
@@ -33,6 +36,32 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
|
||||
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const current = state.sessions[serverId]?.agents.get(agentId);
|
||||
return current
|
||||
? {
|
||||
provider: current.provider,
|
||||
cwd: current.cwd,
|
||||
runtimeModelId: current.runtimeInfo?.model ?? null,
|
||||
configuredModelId: current.model ?? null,
|
||||
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: current.thinkingOptionId ?? null,
|
||||
availableModes: current.availableModes,
|
||||
currentModeId: current.currentModeId,
|
||||
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
|
||||
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
|
||||
}
|
||||
|
||||
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
|
||||
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
|
||||
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
|
||||
|
||||
@@ -60,9 +60,8 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
||||
import { TimelineSyncStatus } from "@/timeline/sync-status";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
@@ -1384,28 +1383,7 @@ function TimelineSyncErrorCallout({
|
||||
* without its composer.
|
||||
*/
|
||||
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const current = state.sessions[serverId]?.agents.get(agentId);
|
||||
return current
|
||||
? {
|
||||
provider: current.provider,
|
||||
cwd: current.cwd,
|
||||
runtimeModelId: current.runtimeInfo?.model ?? null,
|
||||
configuredModelId: current.model ?? null,
|
||||
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: current.thinkingOptionId ?? null,
|
||||
availableModes: current.availableModes,
|
||||
currentModeId: current.currentModeId,
|
||||
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
|
||||
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
|
||||
const text = useAgentFactsLine(serverId, agentId);
|
||||
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
changeRequestNumber: null,
|
||||
statusEnteredAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
@@ -219,14 +220,16 @@ function SubagentsTrackRow({
|
||||
({ active }: { active: boolean }) => (
|
||||
<>
|
||||
<WorkspaceTabIcon presentation={presentation} backdrop={active ? "surface2" : "surface1"} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
{presentation.subtitle ? (
|
||||
<Text style={styles.rowTrailing} numberOfLines={1}>
|
||||
{presentation.subtitle}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{row.kind === "paseo" ? (
|
||||
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
|
||||
) : (
|
||||
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
|
||||
)}
|
||||
</View>
|
||||
{row.kind === "paseo" ? (
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
@@ -247,6 +250,7 @@ function SubagentsTrackRow({
|
||||
presentation,
|
||||
row.kind,
|
||||
row.id,
|
||||
serverId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -262,6 +266,21 @@ function SubagentsTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** The same facts line the agent's sheet shows when it opens without its composer. */
|
||||
function PaseoSubagentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const text = useAgentFactsLine(serverId, agentId);
|
||||
return <SubagentFacts rowId={agentId} text={text} />;
|
||||
}
|
||||
|
||||
function SubagentFacts({ rowId, text }: { rowId: string; text: string | null }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<Text style={styles.rowFacts} numberOfLines={1} testID={`subagents-track-row-facts-${rowId}`}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentRowActions({
|
||||
rowId,
|
||||
displayLabel,
|
||||
@@ -360,9 +379,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// Trailing metadata — provider context on a subagent row, progress on the archive row. No width
|
||||
// cap: the panel's own ceiling bounds it. It shrinks twice as fast as the label, so a wordy
|
||||
// provider subtitle gives way first instead of squeezing the thing that names the row.
|
||||
// Title over facts. The facts sit under the title rather than beside it, so a long task name
|
||||
// keeps the row's full width and neither line truncates the other.
|
||||
rowText: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: "auto",
|
||||
minWidth: 0,
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
rowFacts: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
// Progress on the archive row. It shrinks twice as fast as the label, so a long retry count
|
||||
// gives way first instead of squeezing the thing that names the row.
|
||||
rowTrailing: {
|
||||
flexShrink: 2,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface WorkspaceSummary {
|
||||
archivingAt?: string;
|
||||
/** Pull/merge request number backing this workspace, so a query can find it. */
|
||||
changeRequestNumber: number | null;
|
||||
statusEnteredAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
@@ -161,6 +162,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
|
||||
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
|
||||
changeRequestNumber: toChangeRequestNumber(workspace),
|
||||
statusEnteredAt: workspace.statusEnteredAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Prompts and results can be one enormous line. Summaries are capped here so no
|
||||
* consumer relies on its renderer to clip them.
|
||||
*/
|
||||
export const MAX_SUMMARY_CHARS = 120;
|
||||
// Bounds the whitespace scan: inputs can be megabytes.
|
||||
const SCAN_WINDOW_CHARS = MAX_SUMMARY_CHARS * 4;
|
||||
|
||||
export function toSummaryText(value: string): string | undefined {
|
||||
const isPartial = value.length > SCAN_WINDOW_CHARS;
|
||||
const collapsed = value.slice(0, SCAN_WINDOW_CHARS).replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) return undefined;
|
||||
if (collapsed.length <= MAX_SUMMARY_CHARS && !isPartial) return collapsed;
|
||||
const kept = dropTrailingHighSurrogate(collapsed.slice(0, MAX_SUMMARY_CHARS - 1)).trimEnd();
|
||||
return `${kept}…`;
|
||||
}
|
||||
|
||||
function dropTrailingHighSurrogate(text: string): string {
|
||||
const last = text.charCodeAt(text.length - 1);
|
||||
return last >= 0xd800 && last <= 0xdbff ? text.slice(0, -1) : text;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildAgentMessageDisplay } from "./agent-message-display.js";
|
||||
import type { ToolCallTimelineItem } from "./agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
|
||||
import {
|
||||
buildAgentMessageDisplay,
|
||||
buildPaseoToolDisplay,
|
||||
} from "./paseo-tool-presentation/index.js";
|
||||
import { stripCwdPrefix } from "./path-utils.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
@@ -8,6 +10,8 @@ export type ToolCallDisplayInput = Pick<
|
||||
"name" | "status" | "error" | "metadata" | "detail" | "agentMessage"
|
||||
> & {
|
||||
cwd?: string;
|
||||
/** Title of the agent a Paseo call targets, when the caller knows it. */
|
||||
targetAgentTitle?: string;
|
||||
};
|
||||
|
||||
export interface ToolCallDisplayModel {
|
||||
@@ -34,12 +38,6 @@ function humanizeToolName(name: string): string {
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
if (isPaseoToolName(trimmed)) {
|
||||
const leaf = getPaseoToolLeafName(trimmed);
|
||||
if (leaf) {
|
||||
return humanizeToolName(leaf);
|
||||
}
|
||||
}
|
||||
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
|
||||
return trimmed;
|
||||
}
|
||||
@@ -152,8 +150,20 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
||||
if (input.agentMessage) {
|
||||
return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName };
|
||||
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
|
||||
const paseoDisplay = input.agentMessage
|
||||
? buildAgentMessageDisplay(input.agentMessage)
|
||||
: buildPaseoToolDisplay({
|
||||
toolName: input.name,
|
||||
detail: input.detail,
|
||||
targetAgentTitle: input.targetAgentTitle,
|
||||
});
|
||||
if (paseoDisplay) {
|
||||
return {
|
||||
displayName: paseoDisplay.displayName,
|
||||
...(paseoDisplay.summary ? { summary: paseoDisplay.summary } : {}),
|
||||
...(errorText ? { errorText } : {}),
|
||||
};
|
||||
}
|
||||
const canonicalDisplay = buildCanonicalDetailDisplay(input);
|
||||
const unknownDetailOverride = buildUnknownDetailOverride(input);
|
||||
@@ -162,7 +172,6 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
||||
canonicalDisplay.displayName ??
|
||||
humanizeToolName(input.name);
|
||||
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
|
||||
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
|
||||
|
||||
function toolCallItem(params: {
|
||||
callId: string;
|
||||
@@ -78,6 +79,28 @@ describe("curateAgentActivity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders delivered agent messages with their event, sender, and body", () => {
|
||||
const delivered = (source: Parameters<typeof formatAgentMessage>[0]["source"], text: string) =>
|
||||
projectAgentMessage({
|
||||
type: "user_message",
|
||||
text: formatAgentMessage({ id: `msg-${text.length}`, source, text }),
|
||||
})!;
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
delivered(
|
||||
{ kind: "agent-message", agentId: "agt_lead", title: "CI lead" },
|
||||
"Take ownership.\nRead the handoff.",
|
||||
),
|
||||
delivered(
|
||||
{ kind: "agent-notification", agentId: "agt_child", event: "finished" },
|
||||
"Child is done.",
|
||||
),
|
||||
];
|
||||
|
||||
expect(curateAgentActivity(timeline)).toBe(
|
||||
"[Message received] CI lead\nTake ownership.\nRead the handoff.\n[Agent finished] agt_child\nChild is done.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders user/assistant/reasoning entries", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Hello" },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
|
||||
@@ -119,6 +121,17 @@ function formatToolCallEntry(
|
||||
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
|
||||
}
|
||||
|
||||
function appendAgentMessageEntries(entries: ActivityEntry[], message: AgentMessage) {
|
||||
const display = buildAgentMessageDisplay(message);
|
||||
entries.push(
|
||||
activityEntry(
|
||||
display.summary ? `[${display.displayName}] ${display.summary}` : `[${display.displayName}]`,
|
||||
),
|
||||
);
|
||||
const body = message.text.trim();
|
||||
if (body) entries.push(activityEntry(body));
|
||||
}
|
||||
|
||||
function curateProjectedActivityEntries(
|
||||
items: readonly AgentTimelineItem[],
|
||||
options?: ActivityCuratorOptions,
|
||||
@@ -146,6 +159,10 @@ function curateProjectedActivityEntries(
|
||||
appendTextEntry(entries, item, options);
|
||||
break;
|
||||
case "tool_call": {
|
||||
if (item.agentMessage) {
|
||||
appendAgentMessageEntries(entries, item.agentMessage);
|
||||
break;
|
||||
}
|
||||
entries.push(formatToolCallEntry(item, options));
|
||||
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
|
||||
entries.push(activityEntry(item.detail.log.trim()));
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProviderOptions,
|
||||
ToolPolicy,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
@@ -342,6 +343,8 @@ interface ToolCallBase {
|
||||
name: string;
|
||||
detail: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Set when the row is a delivered agent message rather than a provider tool call. */
|
||||
agentMessage?: AgentMessage;
|
||||
}
|
||||
|
||||
type ToolCallRunningTimelineItem = ToolCallBase & {
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
@@ -888,6 +889,40 @@ describe("bundled Paseo skill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paseo tool timeline presentation", () => {
|
||||
it("catalogs a timeline label for every advertised tool", async () => {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: new BoundaryAgentManagerFake() as AgentManager,
|
||||
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
|
||||
providerSnapshotManager:
|
||||
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
||||
browserToolsEnabled: true,
|
||||
browserToolsBroker: new FakeBrowserToolsBroker({
|
||||
requestId: "req-catalog",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
}) as BrowserToolsBroker,
|
||||
callerAgentId: "agent-1",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const client = await connectInMemoryMcpClient(server);
|
||||
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
const cataloged = new Set<string>(PASEO_TOOL_NAMES);
|
||||
// speak renders as a voice message, not a tool call row.
|
||||
const uncataloged = tools
|
||||
.map((tool) => tool.name)
|
||||
.filter((name) => name !== "speak" && !cataloged.has(name));
|
||||
expect(tools.length).toBeGreaterThan(40);
|
||||
expect(uncataloged).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -761,6 +761,72 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await expect.poll(harness.closeCount).toBe(1);
|
||||
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
|
||||
});
|
||||
|
||||
test("delivers a supported steer to the active turn", async () => {
|
||||
const harness = createProviderHarness({
|
||||
capabilities: [...CAPABILITIES, "prompt.steer"],
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.prompt") return false;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "steer", turnId: "turn-1" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
|
||||
try {
|
||||
await expect(
|
||||
session.steerActiveTurn!("follow up", {
|
||||
expectedTurnId: "turn-1",
|
||||
clientMessageId: "steer-1",
|
||||
clearPendingPermissions: true,
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
expect(harness.inputs.find((input) => input.type === "session.prompt")).toMatchObject({
|
||||
prompt: {
|
||||
delivery: "steer",
|
||||
clientMessageId: "steer-1",
|
||||
input: { type: "message", content: [{ type: "text", text: "follow up" }] },
|
||||
clearPendingPermissions: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns unavailable when steering a provider without prompt.steer capability", async () => {
|
||||
const harness = createProviderHarness({
|
||||
capabilities: [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"permission",
|
||||
],
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
const result = await session.steerActiveTurn!("hello", { expectedTurnId: "turn-1" });
|
||||
expect(result).toEqual({ status: "unavailable" });
|
||||
expect(harness.inputs.filter((input) => input.type === "session.prompt")).toHaveLength(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
|
||||
|
||||
@@ -1175,6 +1175,9 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const result = await this.bridge.prompt({
|
||||
clientMessageId: options.clientMessageId ?? randomUUID(),
|
||||
delivery: "steer",
|
||||
|
||||
+136
-113
@@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns(
|
||||
seq: rows.length + 1,
|
||||
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
|
||||
item: event.item,
|
||||
...(currentTurnId ? { turnId: currentTurnId } : {}),
|
||||
...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}),
|
||||
});
|
||||
}
|
||||
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
|
||||
@@ -469,122 +469,145 @@ describe("background Claude subagents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_background",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: true,
|
||||
test.each(["before", "after"])(
|
||||
"keeps one logged Task card when child tools start %s the parent finishes",
|
||||
async (timing) => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_background",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_background",
|
||||
content: "Async agent launched successfully.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(timing === "after"
|
||||
? [
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "bg-session",
|
||||
permissionMode: "default",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_ls",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1", description: "List files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(timing === "before"
|
||||
? [
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "bg-session",
|
||||
permissionMode: "default",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: "bg-task",
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
|
||||
|
||||
const cards = projectTimelineRows({ rows, mode: "projected" })
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_background",
|
||||
content: "Async agent launched successfully.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_ls",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1", description: "List files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: "bg-task",
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
|
||||
|
||||
const cards = projectTimelineRows({ rows, mode: "projected" })
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
|
||||
@@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly sidechainTracker = new ClaudeSidechainTracker({
|
||||
getToolInput: (toolUseId) =>
|
||||
this.toolUseCache.get(toolUseId)?.input ??
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
|
||||
this.settledSubagentToolCalls.get(toolUseId)?.input ??
|
||||
null,
|
||||
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
|
||||
// identity and status from frames for them. Detecting the capability beats comparing version
|
||||
@@ -2258,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
|
||||
// A background child streams its frames after Claude has already answered the call, so its
|
||||
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
|
||||
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
|
||||
// Late card updates retain the turn that answered the call.
|
||||
needsSyntheticParentToolCard: (toolUseId) =>
|
||||
(this.toolUseCache.has(toolUseId) ||
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
|
||||
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
|
||||
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
|
||||
getSettledParentToolCall: (toolUseId) =>
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
|
||||
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
|
||||
});
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private persistedProviderSubagentEvents: Extract<
|
||||
@@ -4490,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
),
|
||||
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
|
||||
const routedId = canonicalSubagentId ?? parentToolUseId;
|
||||
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
|
||||
const settled = this.settledSubagentToolCalls.get(routedId);
|
||||
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
|
||||
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
|
||||
return event;
|
||||
});
|
||||
return [...runtimeEvents, ...sidechainEvents];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5145,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
const turnId =
|
||||
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5621,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
|
||||
const settled = this.settledSubagentToolCalls.get(subagentId);
|
||||
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
return settled && settled.turnId === currentTurnId ? settled : undefined;
|
||||
}
|
||||
|
||||
/** A finished child streams no more frames, so its settled call has nothing left to update. */
|
||||
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
|
||||
for (const observation of observations) {
|
||||
if (observation.kind === "status" && observation.status !== "running") {
|
||||
this.settledSubagentToolCalls.delete(observation.id);
|
||||
this.sidechainTracker.delete(observation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
|
||||
describe("ClaudeSidechainTracker", () => {
|
||||
it("retains a background child's actions across the parent finishing", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||
isDescriptorOwnedElsewhere: () => true,
|
||||
});
|
||||
const action = (id: string, command: string) =>
|
||||
({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||
}) as unknown as SDKMessage;
|
||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
||||
expect(tracker.finishAll("completed")).toEqual([]);
|
||||
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
tracker.delete("task-1");
|
||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({
|
||||
|
||||
@@ -199,7 +199,7 @@ export class ClaudeSidechainTracker {
|
||||
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
if (this.isDescriptorOwnedElsewhere()) {
|
||||
this.activeSidechains.clear();
|
||||
// The task protocol owns child completion; the parent finishing does not end its children.
|
||||
return events;
|
||||
}
|
||||
for (const [id, state] of this.activeSidechains) {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("background cards retain child actions after the parent finishes (#6253)", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger,
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "bypassPermissions",
|
||||
cwd,
|
||||
title: "Background action log reproduction",
|
||||
});
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.',
|
||||
);
|
||||
await client.waitForFinish(agent.id, 180_000);
|
||||
const atFinish = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { subagents } = await client.listProviderSubagents(agent.id);
|
||||
return subagents.length === 2 && subagents.every((child) => child.status === "completed");
|
||||
},
|
||||
{ timeout: 180_000, interval: 1000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const { subagents } = await client.listProviderSubagents(agent.id);
|
||||
const children = await Promise.all(
|
||||
subagents.map(async (child) => ({
|
||||
description: child.description,
|
||||
timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const final = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
],
|
||||
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
|
||||
);
|
||||
expect(stdout).toContain("[Bash] ls -1");
|
||||
expect(stdout).toContain("[Bash] pwd");
|
||||
expect(
|
||||
atFinish.entries.filter(
|
||||
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
const cards = final.entries
|
||||
.map((row) => row.item)
|
||||
.filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent");
|
||||
expect(cards).toHaveLength(2);
|
||||
for (const child of children) {
|
||||
expect(
|
||||
child.timeline.rows.some(
|
||||
(row) => row.item.type === "tool_call" && row.item.name === "Bash",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const card of cards) {
|
||||
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 420_000);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user