mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a1e60563 | ||
|
|
5ce016bb1f | ||
|
|
a5c566f807 | ||
|
|
9521ee9955 | ||
|
|
bd5061e7bd | ||
|
|
df7f1f0b75 | ||
|
|
cf62b00911 | ||
|
|
efd6d95f91 | ||
|
|
1a6a9fa8d0 | ||
|
|
a7a21d91c2 | ||
|
|
b2da58b5bf | ||
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 |
+158
-95
@@ -8,9 +8,13 @@ on:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref cancels the run still in progress, main included.
|
||||
# A full run costs about 200 runner-minutes against the org's 20 concurrent
|
||||
# jobs, and a superseded main run only answers a question the newer commit
|
||||
# answers again.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -54,10 +58,10 @@ jobs:
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
quality:
|
||||
name: quality
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -68,36 +72,63 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
id: install
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
# Explicit status checks keep independent domains running after a failure.
|
||||
# A failed step still fails quality; no continue-on-error or success shim.
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
run: npm run format:check
|
||||
|
||||
lint:
|
||||
name: lint
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Lint lockfile
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Build relay
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build client dependencies
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
@@ -143,43 +174,56 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
steps:
|
||||
- &server_capacity
|
||||
name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- &server_checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
- &server_fetch
|
||||
name: Fetch origin/main (worktree tests)
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
- &server_agent_clis
|
||||
name: Install agent CLIs for provider tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
- &server_build
|
||||
name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
env: &server_test_env
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
- &server_plugin_tests
|
||||
name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
- &server_hub_contract
|
||||
name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -188,7 +232,41 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server unit tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
|
||||
- *server_plugin_tests
|
||||
- *server_hub_contract
|
||||
|
||||
# Keep the serial integration chain off the Windows unit critical path.
|
||||
server-integration-windows:
|
||||
name: server-integration (windows-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server integration tests
|
||||
run: npm run test:integration --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
@@ -332,52 +410,19 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
name: sdk-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/4)
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
- name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -405,9 +450,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# 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.
|
||||
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -420,56 +465,74 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/4)
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/4)
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/4)
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
playwright-6:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
playwright-7:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
playwright-8:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
+3
-1
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -126,6 +126,8 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -259,3 +259,71 @@ async function openInlineRegressionFile(page: Page, path: string) {
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -118,8 +118,8 @@ export function ChatFind({
|
||||
}, [active, isInteractive, model]);
|
||||
const close = useCallback(() => {
|
||||
model.close();
|
||||
root.current?.focus({ preventScroll: true });
|
||||
}, [model]);
|
||||
viewportRef.current?.focus?.();
|
||||
}, [model, viewportRef]);
|
||||
let status = "";
|
||||
if (state.phase === "searching") status = t("paneFind.searching");
|
||||
else if (state.phase === "loading") status = t("paneFind.loading");
|
||||
|
||||
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
|
||||
]);
|
||||
expect(findRenderedMatches(row, ".another")).toEqual([]);
|
||||
});
|
||||
|
||||
function pluginRow(html: string) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.pluginRow = "true";
|
||||
row.dataset.messageId = "message-1";
|
||||
row.innerHTML = html;
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
|
||||
const row = pluginRow(
|
||||
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
|
||||
"needle",
|
||||
"needle",
|
||||
]);
|
||||
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
|
||||
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves rows without the plugin marker limited to marked message text", () => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = "<div>needle</div>";
|
||||
document.body.append(row);
|
||||
expect(findRenderedMatches(row, "needle")).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts only drawn text in a plugin row, including an opened popover", () => {
|
||||
const row = pluginRow(
|
||||
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
|
||||
row.querySelector<HTMLElement>("[popover]")!.showPopover();
|
||||
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps text around nested plugin blocks in separate occurrences", () => {
|
||||
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
|
||||
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
|
||||
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
@@ -21,28 +21,55 @@ export function findMessageMatches(
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
function renderedTextGroups(row: HTMLElement): Text[][] {
|
||||
const groups: Text[][] = [];
|
||||
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
|
||||
// whole row is searched and each div (a React Native View or Text) bounds a match.
|
||||
const pluginRow = row.dataset.pluginRow === "true";
|
||||
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
|
||||
const contents = pluginRow
|
||||
? [row]
|
||||
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
|
||||
for (const content of contents) {
|
||||
let group: { block: Element; texts: Text[] } | undefined;
|
||||
const walker = document.createTreeWalker(
|
||||
content,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node instanceof Element) {
|
||||
// Even an empty nested block separates the text before and after it.
|
||||
if (node.matches(blocks)) group = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
if (
|
||||
pluginRow &&
|
||||
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
)
|
||||
continue;
|
||||
const block = node.parentElement?.closest(blocks) ?? content;
|
||||
if (group?.block !== block) {
|
||||
group = { block, texts: [] };
|
||||
groups.push(group.texts);
|
||||
}
|
||||
group.texts.push(node);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
const groups = new Map<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
const block = node.parentElement?.closest(BLOCKS) ?? content;
|
||||
const texts = groups.get(block) ?? [];
|
||||
texts.push(node);
|
||||
groups.set(block, texts);
|
||||
}
|
||||
}
|
||||
const pattern = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
const ranges: Range[] = [];
|
||||
for (const texts of groups.values()) {
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
|
||||
describe("native scroll-to-bottom animation", () => {
|
||||
it("jumps across unmounted history instead of animating through blank rows", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps near-bottom movement eligible for animation", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds travel by the bottom-side overscan, not the whole window", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not animate before the viewport has been measured", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function shouldAnimateNativeScrollToBottom(input: {
|
||||
offsetY: number;
|
||||
viewportHeight: number;
|
||||
windowSize: number;
|
||||
}): boolean {
|
||||
// VirtualizedList splits the extra window around the viewport. An animated
|
||||
// jump farther than the bottom-side overscan crosses unmounted history.
|
||||
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
|
||||
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardStreamInset } from "@/keyboard/shift";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// A history row can contain an entire long reply. Keep synchronous native
|
||||
// Markdown measurement bounded while FlatList fills the surrounding window.
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
|
||||
scrollToBottom: () => {
|
||||
setFollowOutput(true);
|
||||
cancelPendingStickToBottom();
|
||||
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
|
||||
}
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
|
||||
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
serverId={resolvedServerId}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -795,7 +796,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
// Read through a stable event so live group updates do not change the renderer identity
|
||||
|
||||
@@ -1,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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,15 +6,18 @@ import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { NestedSettingsRows, useSettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
interface SettingsCollapsibleRowProps {
|
||||
label: string;
|
||||
/** A short status shown before the chevron, so the row says something while collapsed. */
|
||||
summary?: string;
|
||||
defaultExpanded?: boolean;
|
||||
testID?: string;
|
||||
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
|
||||
/** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,7 @@ interface SettingsCollapsibleRowProps {
|
||||
*/
|
||||
export function SettingsCollapsibleRow({
|
||||
label,
|
||||
summary,
|
||||
defaultExpanded = false,
|
||||
testID,
|
||||
children,
|
||||
@@ -37,13 +41,14 @@ export function SettingsCollapsibleRow({
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const layout = useSettingsRowLayout();
|
||||
const headerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
layout.row,
|
||||
hovered && styles.headerHovered,
|
||||
pressed && styles.headerPressed,
|
||||
],
|
||||
[],
|
||||
[layout],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
@@ -55,7 +60,7 @@ export function SettingsCollapsibleRow({
|
||||
<Pressable
|
||||
{...webExpandedProps}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityLabel={summary ? `${label}, ${summary}` : label}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggle}
|
||||
style={headerStyle}
|
||||
@@ -64,8 +69,9 @@ export function SettingsCollapsibleRow({
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
</View>
|
||||
{summary ? <Text style={styles.summary}>{summary}</Text> : null}
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
@@ -75,18 +81,34 @@ export function SettingsCollapsibleRow({
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
{expanded
|
||||
? Children.toArray(children).map((child, index) => (
|
||||
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
|
||||
{child}
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
{expanded ? (
|
||||
<View style={styles.children}>
|
||||
<NestedSettingsRows>
|
||||
{Children.toArray(children).map((child, index) => (
|
||||
<View
|
||||
key={isValidElement(child) ? child.key : index}
|
||||
style={settingsStyles.rowBorder}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
</NestedSettingsRows>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
summary: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
// The revealed rows keep the card's rails; the tint is what groups them under their header.
|
||||
children: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
headerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
@@ -6,7 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedInfo = withUnistyles(Info);
|
||||
type TipIconComponent = ComponentType<{ size: number; color: string }>;
|
||||
|
||||
function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) {
|
||||
return <Icon size={ICON_SIZE.sm} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedTipIcon = withUnistyles(TipIcon);
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -14,27 +20,37 @@ interface SettingsInfoTipProps {
|
||||
/** Header the tip belongs to; used for the accessibility label. */
|
||||
title: string;
|
||||
info: ReactNode;
|
||||
/** Defaults to the info icon. */
|
||||
icon?: TipIconComponent;
|
||||
/** Defaults to "About {title}". */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one way a settings group or section explains itself: an info icon on the
|
||||
* header that opens a tooltip. Never a paragraph under the header — see
|
||||
* docs/design.md §7.
|
||||
* docs/design.md §7. Rows use it the same way to keep secondary details on one line.
|
||||
*/
|
||||
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
|
||||
export function SettingsInfoTip({
|
||||
title,
|
||||
info,
|
||||
icon = Info,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: SettingsInfoTipProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
accessibilityLabel={accessibilityLabel ?? t("settings.groupInfo", { title })}
|
||||
testID={testID}
|
||||
hitSlop={8}
|
||||
style={styles.button}
|
||||
>
|
||||
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
|
||||
<ThemedTipIcon icon={icon} uniProps={mutedIconMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" offset={8}>
|
||||
|
||||
@@ -24,9 +24,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ControlSizeProvider } from "@/components/ui/control-size";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
export { SettingsInfoTip } from "./headings/settings-info-tip";
|
||||
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
|
||||
hint?: ReactNode;
|
||||
@@ -57,26 +60,32 @@ export function SettingsRow({
|
||||
testID,
|
||||
}: AppSettingsRowProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]);
|
||||
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
|
||||
return (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
<View style={styles.label}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
|
||||
<Text style={accessoryTitleStyle}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
)}
|
||||
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
|
||||
{typeof hint === "string" ? <Text style={layout.hint}>{hint}</Text> : hint}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{children ? <View style={styles.control}>{children}</View> : null}
|
||||
{children ? (
|
||||
<View style={styles.control}>
|
||||
<ControlSizeProvider size={layout.controlSize}>{children}</ControlSizeProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +184,7 @@ export function SettingsInput({
|
||||
editable={!disabled}
|
||||
secureTextEntry={secureTextEntry}
|
||||
accessibilityLabel={row.label}
|
||||
size={compact ? "md" : "sm"}
|
||||
size={compact ? "md" : undefined}
|
||||
style={styles.input}
|
||||
/>
|
||||
</SettingsRow>
|
||||
@@ -185,7 +194,7 @@ export function SettingsInput({
|
||||
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
|
||||
<Button variant="outline" onPress={onPress} disabled={disabled}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function mountRows() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() =>
|
||||
root?.render(
|
||||
<SettingsCard>
|
||||
<SettingsRow label="Card row" testID="card-row">
|
||||
<Button variant="outline" testID="card-button">
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
|
||||
<SettingsRow label="Nested with a button" testID="nested-with-button">
|
||||
<Button variant="outline" testID="nested-button">
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Nested without a button" testID="nested-without-button" />
|
||||
</SettingsCollapsibleRow>
|
||||
</SettingsCard>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function element(testID: string): HTMLElement {
|
||||
const found = document.querySelector(`[data-testid="${testID}"]`);
|
||||
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function heightOf(testID: string): number {
|
||||
return element(testID).getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
function titleFontSize(rowTestID: string, label: string): number {
|
||||
const title = [...element(rowTestID).querySelectorAll("div")].find(
|
||||
(node) => node.textContent === label && node.children.length === 0,
|
||||
);
|
||||
if (!title) throw new Error(`No title ${label}`);
|
||||
return Number.parseFloat(getComputedStyle(title).fontSize);
|
||||
}
|
||||
|
||||
describe("settings row density", () => {
|
||||
it("lines up nested rows with and without a button, tighter than card rows", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button"));
|
||||
expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row"));
|
||||
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
|
||||
});
|
||||
|
||||
it("sizes nested text and controls one step below card rows", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button"));
|
||||
expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan(
|
||||
titleFontSize("card-row", "Card row"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import type { ContainedControlSize } from "@/components/ui/control-size";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
|
||||
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
|
||||
// never knows on its own that it is nested.
|
||||
const NestedRowsContext = createContext(false);
|
||||
|
||||
export function NestedSettingsRows({ children }: { children: ReactNode }) {
|
||||
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
|
||||
}
|
||||
|
||||
export interface SettingsRowLayout {
|
||||
row: StyleProp<ViewStyle>;
|
||||
title: StyleProp<TextStyle>;
|
||||
hint: StyleProp<TextStyle>;
|
||||
/** The size of the controls in the row's trailing slot. */
|
||||
controlSize: ContainedControlSize;
|
||||
}
|
||||
|
||||
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
|
||||
export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
const nested = useContext(NestedRowsContext);
|
||||
return useMemo(
|
||||
() =>
|
||||
nested
|
||||
? {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "xs",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "sm",
|
||||
},
|
||||
[nested],
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
|
||||
},
|
||||
nestedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
import {
|
||||
default as React,
|
||||
useCallback,
|
||||
@@ -162,7 +163,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
export function Button({
|
||||
children,
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
size: sizeProp,
|
||||
leftIcon,
|
||||
trailing,
|
||||
style,
|
||||
@@ -185,6 +186,8 @@ export function Button({
|
||||
}
|
||||
>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "md";
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
let variantStyle: ViewStyle;
|
||||
|
||||
@@ -81,6 +81,8 @@ describe("control geometry", () => {
|
||||
it("keeps field text sizing tied to control size", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.fieldTextXs.fontSize).toBe(12);
|
||||
expect(geometry.fieldTextXs.lineHeight).toBe(17);
|
||||
expect(geometry.fieldTextSm.fontSize).toBe(14);
|
||||
expect(geometry.fieldTextSm.lineHeight).toBe(20);
|
||||
expect(geometry.fieldTextMd.fontSize).toBe(14);
|
||||
@@ -95,6 +97,13 @@ describe("control geometry", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
const borderWidth = theme.borderWidth[1];
|
||||
|
||||
expect(geometry.fieldControlXs.minHeight).toBe(28);
|
||||
expect(
|
||||
geometry.fieldTextXs.lineHeight +
|
||||
geometry.fieldControlXs.paddingVertical * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlXs.minHeight);
|
||||
|
||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||
export type FieldControlSize = "sm" | "md";
|
||||
export type FieldControlSize = "xs" | "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
@@ -188,8 +188,19 @@ export function resolveControlInteractionStyles(
|
||||
|
||||
export function createControlGeometry(theme: Theme) {
|
||||
const controlBorderWidth = theme.borderWidth[1];
|
||||
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
|
||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldControlXs = {
|
||||
minHeight: CONTROL_HEIGHTS.tight,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: fieldVerticalPadding(
|
||||
CONTROL_HEIGHTS.tight,
|
||||
fieldTextXsLineHeight,
|
||||
controlBorderWidth,
|
||||
),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
};
|
||||
const fieldControlSm = {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) {
|
||||
),
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
};
|
||||
const fieldTextXs = {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: fieldTextXsLineHeight,
|
||||
};
|
||||
const fieldTextSm = {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextSmLineHeight,
|
||||
@@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
alert,
|
||||
formTextInputXs: {
|
||||
...fieldControlXs,
|
||||
...fieldTextXs,
|
||||
},
|
||||
formTextInputSm: {
|
||||
...fieldControlSm,
|
||||
...fieldTextSm,
|
||||
@@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) {
|
||||
...fieldControlMd,
|
||||
...fieldTextMd,
|
||||
},
|
||||
fieldControlXs,
|
||||
fieldControlSm,
|
||||
fieldControlMd,
|
||||
fieldTextXs,
|
||||
fieldTextSm,
|
||||
fieldTextMd,
|
||||
controlRest: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
/** The control sizes a container can ask of the controls placed in it. */
|
||||
export type ContainedControlSize = "xs" | "sm";
|
||||
|
||||
// A row decides how big its controls are, so a button or field placed in it needs no size of
|
||||
// its own. A size passed to the control still wins.
|
||||
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
|
||||
|
||||
export function ControlSizeProvider({
|
||||
size,
|
||||
children,
|
||||
}: {
|
||||
size: ContainedControlSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
|
||||
}
|
||||
|
||||
/** The size the surrounding container asks for, or null outside one. */
|
||||
export function useContainedControlSize(): ContainedControlSize | null {
|
||||
return useContext(ControlSizeContext);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
@@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
leading,
|
||||
size = "sm",
|
||||
size: sizeProp,
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "sm";
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
|
||||
@@ -23,6 +23,16 @@ import {
|
||||
resolveControlInteractionStyles,
|
||||
type FieldControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
|
||||
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
|
||||
FieldControlSize,
|
||||
string
|
||||
>;
|
||||
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
|
||||
FieldControlSize,
|
||||
string
|
||||
>;
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
@@ -159,11 +169,13 @@ function assignTextInputRef(
|
||||
}
|
||||
|
||||
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
|
||||
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
|
||||
function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "md";
|
||||
const isDisabled = editable === false;
|
||||
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
||||
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
||||
const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]];
|
||||
const inputSizeStyle = formInputStyles[FIELD_TEXT[size]];
|
||||
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
||||
const setInputRef = useCallback(
|
||||
(node: EditingTextInputHandle | null) => {
|
||||
@@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => {
|
||||
chrome: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chromeXs: {
|
||||
...geometry.fieldControlXs,
|
||||
},
|
||||
chromeSm: {
|
||||
...geometry.fieldControlSm,
|
||||
},
|
||||
@@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => {
|
||||
outlineColor: "transparent",
|
||||
outlineWidth: 0,
|
||||
},
|
||||
inputXs: {
|
||||
...geometry.fieldTextXs,
|
||||
},
|
||||
inputSm: {
|
||||
...geometry.fieldTextSm,
|
||||
},
|
||||
|
||||
@@ -2106,6 +2106,7 @@ export const ar: TranslationResources = {
|
||||
connections: "اتصالات",
|
||||
agents: "Agents",
|
||||
metadata: "البيانات الوصفية",
|
||||
voice: "الصوت",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
@@ -2128,6 +2129,75 @@ export const ar: TranslationResources = {
|
||||
docs: "الوثائق",
|
||||
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
|
||||
},
|
||||
voice: {
|
||||
offline: "اتصل بهذا المضيف لضبط الصوت.",
|
||||
updateHost: "حدّث المضيف لضبط الصوت.",
|
||||
saveError: "تعذّر تحديث إعدادات الصوت",
|
||||
provider: "المزوّد",
|
||||
providers: {
|
||||
local: "محلي",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "نموذج تحويل الكلام إلى نص",
|
||||
ttsModel: "نموذج الصوت",
|
||||
voice: "الصوت",
|
||||
notDownloadedOption: "{{label}} (غير منزّل)",
|
||||
save: "حفظ",
|
||||
retry: "إعادة المحاولة",
|
||||
dictation: {
|
||||
title: "الإملاء",
|
||||
enabled: "الإملاء",
|
||||
enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "وضع الصوت",
|
||||
info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.",
|
||||
enabled: "وضع الصوت",
|
||||
enabledHint: "تحدّث مع وكلائك دون استخدام يديك",
|
||||
sttProvider: "تحويل الكلام إلى نص",
|
||||
ttsProvider: "تحويل النص إلى كلام",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "مفتاح API",
|
||||
apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "مفتاح جديد",
|
||||
removeKey: "إزالة",
|
||||
removeKeyTitle: "إزالة مفتاح OpenAI؟",
|
||||
removeKeyMessage: "ستتوقف الميزات المضبوطة على OpenAI عن العمل حتى تضيف مفتاحًا.",
|
||||
baseUrl: "عنوان URL الأساسي",
|
||||
baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.",
|
||||
missingKeyWarning: "تم اختيار OpenAI لكن لم يُضبط مفتاح API.",
|
||||
},
|
||||
models: {
|
||||
loading: "جارٍ تحميل النماذج...",
|
||||
loadError: "تعذّر تحميل النماذج المحلية",
|
||||
downloaded: "منزّل",
|
||||
inUse: "قيد الاستخدام",
|
||||
recommended: "موصى به",
|
||||
download: "تنزيل",
|
||||
downloading: "جارٍ التنزيل...",
|
||||
downloadingPercent: "جارٍ التنزيل {{percent}}%",
|
||||
extracting: "جارٍ الاستخراج...",
|
||||
delete: "حذف",
|
||||
deleteAction: "حذف {{label}}",
|
||||
deleteTitle: "حذف {{label}}؟",
|
||||
deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.",
|
||||
languages: "لغات {{label}}",
|
||||
moreLanguages: "{{languages}} و{{count}} أخرى",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "المزوّدون",
|
||||
info: "النماذج وبيانات الاعتماد لكل مزوّد. تعمل النماذج المحلية على هذا المضيف وتعرض حجمها ولغاتها قبل التنزيل.",
|
||||
localStt: "تحويل الكلام إلى نص محليًا",
|
||||
localTts: "تحويل النص إلى كلام محليًا",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "تم تنزيل {{count}} نموذج",
|
||||
downloaded_other: "تم تنزيل {{count}} نماذج",
|
||||
configured: "تم الإعداد",
|
||||
notConfigured: "غير مُعد",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
|
||||
@@ -2169,6 +2169,7 @@ export const en = {
|
||||
connections: "Connections",
|
||||
agents: "Agents",
|
||||
metadata: "Metadata",
|
||||
voice: "Voice",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
@@ -2253,6 +2254,75 @@ export const en = {
|
||||
docs: "Docs",
|
||||
saveError: "Unable to update metadata generation",
|
||||
},
|
||||
voice: {
|
||||
offline: "Connect to this host to configure voice.",
|
||||
updateHost: "Update the host to configure voice.",
|
||||
saveError: "Unable to update voice settings",
|
||||
provider: "Provider",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "Speech-to-text model",
|
||||
ttsModel: "Voice model",
|
||||
voice: "Voice",
|
||||
notDownloadedOption: "{{label}} (not downloaded)",
|
||||
save: "Save",
|
||||
retry: "Retry",
|
||||
dictation: {
|
||||
title: "Dictation",
|
||||
enabled: "Dictation",
|
||||
enabledHint: "Speak into the message box instead of typing",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Voice mode",
|
||||
info: "Voice mode transcribes what you say and reads agent replies aloud.",
|
||||
enabled: "Voice mode",
|
||||
enabledHint: "Talk with your agents hands-free",
|
||||
sttProvider: "Speech-to-text",
|
||||
ttsProvider: "Text-to-speech",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API key",
|
||||
apiKeyHint: "Saved on the host and never shown again.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "New key",
|
||||
removeKey: "Remove",
|
||||
removeKeyTitle: "Remove the OpenAI API key?",
|
||||
removeKeyMessage: "Features set to OpenAI stop working until you add a key.",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.",
|
||||
missingKeyWarning: "OpenAI is selected but no API key is set.",
|
||||
},
|
||||
models: {
|
||||
loading: "Loading models...",
|
||||
loadError: "Couldn't load local models",
|
||||
downloaded: "Downloaded",
|
||||
inUse: "In use",
|
||||
recommended: "Recommended",
|
||||
download: "Download",
|
||||
downloading: "Downloading...",
|
||||
downloadingPercent: "Downloading {{percent}}%",
|
||||
extracting: "Extracting...",
|
||||
delete: "Delete",
|
||||
deleteAction: "Delete {{label}}",
|
||||
deleteTitle: "Delete {{label}}?",
|
||||
deleteMessage: "This frees {{size}}. You can download it again later.",
|
||||
languages: "{{label}} languages",
|
||||
moreLanguages: "{{languages}} and {{count}} more",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "Providers",
|
||||
info: "Models and credentials for each provider. Local models run on this host and list their size and languages before you download.",
|
||||
localStt: "Local speech-to-text",
|
||||
localTts: "Local text-to-speech",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "{{count}} model downloaded",
|
||||
downloaded_other: "{{count}} models downloaded",
|
||||
configured: "Configured",
|
||||
notConfigured: "Not configured",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
|
||||
@@ -2154,6 +2154,7 @@ export const es: TranslationResources = {
|
||||
connections: "Conexiones",
|
||||
agents: "Agents",
|
||||
metadata: "Metadatos",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
@@ -2176,6 +2177,77 @@ export const es: TranslationResources = {
|
||||
docs: "Documentación",
|
||||
saveError: "No se pudo actualizar la generación de metadatos",
|
||||
},
|
||||
voice: {
|
||||
offline: "Conéctate a este host para configurar la voz.",
|
||||
updateHost: "Actualiza el host para configurar la voz.",
|
||||
saveError: "No se pudo actualizar la configuración de voz",
|
||||
provider: "Proveedor",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "Modelo de voz a texto",
|
||||
ttsModel: "Modelo de voz",
|
||||
voice: "Voz",
|
||||
notDownloadedOption: "{{label}} (sin descargar)",
|
||||
save: "Guardar",
|
||||
retry: "Reintentar",
|
||||
dictation: {
|
||||
title: "Dictado",
|
||||
enabled: "Dictado",
|
||||
enabledHint: "Habla en el cuadro de mensaje en lugar de escribir",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Modo de voz",
|
||||
info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.",
|
||||
enabled: "Modo de voz",
|
||||
enabledHint: "Habla con tus agentes sin usar las manos",
|
||||
sttProvider: "Voz a texto",
|
||||
ttsProvider: "Texto a voz",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Clave de API",
|
||||
apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "Nueva clave",
|
||||
removeKey: "Quitar",
|
||||
removeKeyTitle: "¿Quitar la clave de API de OpenAI?",
|
||||
removeKeyMessage:
|
||||
"Las funciones configuradas con OpenAI dejarán de funcionar hasta que añadas una clave.",
|
||||
baseUrl: "URL base",
|
||||
baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.",
|
||||
missingKeyWarning:
|
||||
"OpenAI está seleccionado, pero no hay ninguna clave de API configurada.",
|
||||
},
|
||||
models: {
|
||||
loading: "Cargando modelos...",
|
||||
loadError: "No se pudieron cargar los modelos locales",
|
||||
downloaded: "Descargado",
|
||||
inUse: "En uso",
|
||||
recommended: "Recomendado",
|
||||
download: "Descargar",
|
||||
downloading: "Descargando...",
|
||||
downloadingPercent: "Descargando {{percent}} %",
|
||||
extracting: "Extrayendo...",
|
||||
delete: "Eliminar",
|
||||
deleteAction: "Eliminar {{label}}",
|
||||
deleteTitle: "¿Eliminar {{label}}?",
|
||||
deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.",
|
||||
languages: "Idiomas de {{label}}",
|
||||
moreLanguages: "{{languages}} y {{count}} más",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "Proveedores",
|
||||
info: "Modelos y credenciales de cada proveedor. Los modelos locales se ejecutan en este host e indican su tamaño e idiomas antes de descargarlos.",
|
||||
localStt: "Voz a texto local",
|
||||
localTts: "Texto a voz local",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "{{count}} modelo descargado",
|
||||
downloaded_other: "{{count}} modelos descargados",
|
||||
configured: "Configurado",
|
||||
notConfigured: "Sin configurar",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
|
||||
@@ -2153,6 +2153,7 @@ export const fr: TranslationResources = {
|
||||
connections: "Connexions",
|
||||
agents: "Agents",
|
||||
metadata: "Métadonnées",
|
||||
voice: "Voix",
|
||||
workspaces: "Espaces de travail",
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
@@ -2175,6 +2176,76 @@ export const fr: TranslationResources = {
|
||||
docs: "Documentation",
|
||||
saveError: "Impossible de mettre à jour la génération de métadonnées",
|
||||
},
|
||||
voice: {
|
||||
offline: "Connectez-vous à cet hôte pour configurer la voix.",
|
||||
updateHost: "Mettez à jour l'hôte pour configurer la voix.",
|
||||
saveError: "Impossible de mettre à jour les réglages vocaux",
|
||||
provider: "Fournisseur",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "Modèle de transcription",
|
||||
ttsModel: "Modèle de voix",
|
||||
voice: "Voix",
|
||||
notDownloadedOption: "{{label}} (non téléchargé)",
|
||||
save: "Enregistrer",
|
||||
retry: "Réessayer",
|
||||
dictation: {
|
||||
title: "Dictée",
|
||||
enabled: "Dictée",
|
||||
enabledHint: "Parlez dans la zone de message au lieu de taper",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Mode vocal",
|
||||
info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.",
|
||||
enabled: "Mode vocal",
|
||||
enabledHint: "Parlez à vos agents sans les mains",
|
||||
sttProvider: "Transcription",
|
||||
ttsProvider: "Synthèse vocale",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Clé d'API",
|
||||
apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "Nouvelle clé",
|
||||
removeKey: "Supprimer",
|
||||
removeKeyTitle: "Supprimer la clé d'API OpenAI ?",
|
||||
removeKeyMessage:
|
||||
"Les fonctions réglées sur OpenAI cesseront de fonctionner jusqu'à l'ajout d'une clé.",
|
||||
baseUrl: "URL de base",
|
||||
baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.",
|
||||
missingKeyWarning: "OpenAI est sélectionné, mais aucune clé d'API n'est définie.",
|
||||
},
|
||||
models: {
|
||||
loading: "Chargement des modèles...",
|
||||
loadError: "Impossible de charger les modèles locaux",
|
||||
downloaded: "Téléchargé",
|
||||
inUse: "Utilisé",
|
||||
recommended: "Recommandé",
|
||||
download: "Télécharger",
|
||||
downloading: "Téléchargement...",
|
||||
downloadingPercent: "Téléchargement {{percent}} %",
|
||||
extracting: "Extraction...",
|
||||
delete: "Supprimer",
|
||||
deleteAction: "Supprimer {{label}}",
|
||||
deleteTitle: "Supprimer {{label}} ?",
|
||||
deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.",
|
||||
languages: "Langues de {{label}}",
|
||||
moreLanguages: "{{languages}} et {{count}} autres",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "Fournisseurs",
|
||||
info: "Modèles et identifiants de chaque fournisseur. Les modèles locaux s'exécutent sur cet hôte et indiquent leur taille et leurs langues avant le téléchargement.",
|
||||
localStt: "Reconnaissance vocale locale",
|
||||
localTts: "Synthèse vocale locale",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "{{count}} modèle téléchargé",
|
||||
downloaded_other: "{{count}} modèles téléchargés",
|
||||
configured: "Configuré",
|
||||
notConfigured: "Non configuré",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
|
||||
@@ -2127,6 +2127,7 @@ export const ja: TranslationResources = {
|
||||
connections: "接続",
|
||||
agents: "エージェント",
|
||||
metadata: "メタデータ",
|
||||
voice: "音声",
|
||||
workspaces: "ワークスペース",
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
@@ -2149,6 +2150,75 @@ export const ja: TranslationResources = {
|
||||
docs: "ドキュメント",
|
||||
saveError: "メタデータ生成を更新できません",
|
||||
},
|
||||
voice: {
|
||||
offline: "音声を設定するには、このホストに接続してください。",
|
||||
updateHost: "音声を設定するには、ホストを更新してください。",
|
||||
saveError: "音声設定を更新できませんでした",
|
||||
provider: "プロバイダー",
|
||||
providers: {
|
||||
local: "ローカル",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "音声認識モデル",
|
||||
ttsModel: "音声モデル",
|
||||
voice: "ボイス",
|
||||
notDownloadedOption: "{{label}}(未ダウンロード)",
|
||||
save: "保存",
|
||||
retry: "再試行",
|
||||
dictation: {
|
||||
title: "音声入力",
|
||||
enabled: "音声入力",
|
||||
enabledHint: "入力する代わりにメッセージ欄に話しかけます",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "ボイスモード",
|
||||
info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。",
|
||||
enabled: "ボイスモード",
|
||||
enabledHint: "ハンズフリーでエージェントと会話します",
|
||||
sttProvider: "音声認識",
|
||||
ttsProvider: "音声合成",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API キー",
|
||||
apiKeyHint: "ホストに保存され、再表示されません。",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "新しいキー",
|
||||
removeKey: "削除",
|
||||
removeKeyTitle: "OpenAI API キーを削除しますか?",
|
||||
removeKeyMessage: "キーを追加するまで、OpenAI に設定された機能は動作しません。",
|
||||
baseUrl: "ベース URL",
|
||||
baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。",
|
||||
missingKeyWarning: "OpenAI が選択されていますが、API キーが設定されていません。",
|
||||
},
|
||||
models: {
|
||||
loading: "モデルを読み込み中...",
|
||||
loadError: "ローカルモデルを読み込めませんでした",
|
||||
downloaded: "ダウンロード済み",
|
||||
inUse: "使用中",
|
||||
recommended: "おすすめ",
|
||||
download: "ダウンロード",
|
||||
downloading: "ダウンロード中...",
|
||||
downloadingPercent: "ダウンロード中 {{percent}}%",
|
||||
extracting: "展開中...",
|
||||
delete: "削除",
|
||||
deleteAction: "{{label}} を削除",
|
||||
deleteTitle: "{{label}} を削除しますか?",
|
||||
deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。",
|
||||
languages: "{{label}} の対応言語",
|
||||
moreLanguages: "{{languages}} ほか {{count}} 言語",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "プロバイダー",
|
||||
info: "各プロバイダーのモデルと認証情報です。ローカルモデルはこのホストで動作し、ダウンロード前にサイズと対応言語を確認できます。",
|
||||
localStt: "ローカル音声認識",
|
||||
localTts: "ローカル音声合成",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "{{count}} 個のモデルをダウンロード済み",
|
||||
downloaded_other: "{{count}} 個のモデルをダウンロード済み",
|
||||
configured: "設定済み",
|
||||
notConfigured: "未設定",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
|
||||
@@ -2116,6 +2116,7 @@ export const ko: TranslationResources = {
|
||||
connections: "연결",
|
||||
agents: "에이전트",
|
||||
metadata: "메타데이터",
|
||||
voice: "음성",
|
||||
workspaces: "워크스페이스",
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
@@ -2138,6 +2139,75 @@ export const ko: TranslationResources = {
|
||||
docs: "문서",
|
||||
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
|
||||
},
|
||||
voice: {
|
||||
offline: "음성을 설정하려면 이 호스트에 연결하세요.",
|
||||
updateHost: "음성을 설정하려면 호스트를 업데이트하세요.",
|
||||
saveError: "음성 설정을 업데이트할 수 없습니다",
|
||||
provider: "제공자",
|
||||
providers: {
|
||||
local: "로컬",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "음성 인식 모델",
|
||||
ttsModel: "음성 모델",
|
||||
voice: "목소리",
|
||||
notDownloadedOption: "{{label}} (다운로드 안 됨)",
|
||||
save: "저장",
|
||||
retry: "다시 시도",
|
||||
dictation: {
|
||||
title: "받아쓰기",
|
||||
enabled: "받아쓰기",
|
||||
enabledHint: "입력하는 대신 메시지 상자에 말하세요",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "음성 모드",
|
||||
info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.",
|
||||
enabled: "음성 모드",
|
||||
enabledHint: "손을 쓰지 않고 에이전트와 대화하세요",
|
||||
sttProvider: "음성 인식",
|
||||
ttsProvider: "음성 합성",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API 키",
|
||||
apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "새 키",
|
||||
removeKey: "제거",
|
||||
removeKeyTitle: "OpenAI API 키를 제거할까요?",
|
||||
removeKeyMessage: "키를 추가할 때까지 OpenAI로 설정된 기능이 작동하지 않습니다.",
|
||||
baseUrl: "기본 URL",
|
||||
baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.",
|
||||
missingKeyWarning: "OpenAI가 선택되었지만 API 키가 설정되지 않았습니다.",
|
||||
},
|
||||
models: {
|
||||
loading: "모델을 불러오는 중...",
|
||||
loadError: "로컬 모델을 불러올 수 없습니다",
|
||||
downloaded: "다운로드됨",
|
||||
inUse: "사용 중",
|
||||
recommended: "추천",
|
||||
download: "다운로드",
|
||||
downloading: "다운로드 중...",
|
||||
downloadingPercent: "다운로드 중 {{percent}}%",
|
||||
extracting: "압축 해제 중...",
|
||||
delete: "삭제",
|
||||
deleteAction: "{{label}} 삭제",
|
||||
deleteTitle: "{{label}}을(를) 삭제할까요?",
|
||||
deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.",
|
||||
languages: "{{label}} 지원 언어",
|
||||
moreLanguages: "{{languages}} 외 {{count}}개",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "제공자",
|
||||
info: "제공자별 모델과 자격 증명입니다. 로컬 모델은 이 호스트에서 실행되며 다운로드 전에 크기와 지원 언어를 보여줍니다.",
|
||||
localStt: "로컬 음성 인식",
|
||||
localTts: "로컬 음성 합성",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "모델 {{count}}개 다운로드됨",
|
||||
downloaded_other: "모델 {{count}}개 다운로드됨",
|
||||
configured: "설정됨",
|
||||
notConfigured: "설정 안 됨",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
|
||||
@@ -2139,6 +2139,7 @@ export const ptBR: TranslationResources = {
|
||||
connections: "Conexões",
|
||||
agents: "Agentes",
|
||||
metadata: "Metadados",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
@@ -2161,6 +2162,76 @@ export const ptBR: TranslationResources = {
|
||||
docs: "Documentação",
|
||||
saveError: "Não foi possível atualizar a geração de metadados",
|
||||
},
|
||||
voice: {
|
||||
offline: "Conecte-se a este host para configurar a voz.",
|
||||
updateHost: "Atualize o host para configurar a voz.",
|
||||
saveError: "Não foi possível atualizar as configurações de voz",
|
||||
provider: "Provedor",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "Modelo de fala para texto",
|
||||
ttsModel: "Modelo de voz",
|
||||
voice: "Voz",
|
||||
notDownloadedOption: "{{label}} (não baixado)",
|
||||
save: "Salvar",
|
||||
retry: "Tentar novamente",
|
||||
dictation: {
|
||||
title: "Ditado",
|
||||
enabled: "Ditado",
|
||||
enabledHint: "Fale na caixa de mensagem em vez de digitar",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Modo de voz",
|
||||
info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.",
|
||||
enabled: "Modo de voz",
|
||||
enabledHint: "Converse com seus agentes sem usar as mãos",
|
||||
sttProvider: "Fala para texto",
|
||||
ttsProvider: "Texto para fala",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Chave de API",
|
||||
apiKeyHint: "Salva no host e nunca mostrada novamente.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "Nova chave",
|
||||
removeKey: "Remover",
|
||||
removeKeyTitle: "Remover a chave de API da OpenAI?",
|
||||
removeKeyMessage:
|
||||
"Os recursos configurados com a OpenAI param de funcionar até você adicionar uma chave.",
|
||||
baseUrl: "URL base",
|
||||
baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.",
|
||||
missingKeyWarning: "A OpenAI está selecionada, mas nenhuma chave de API foi definida.",
|
||||
},
|
||||
models: {
|
||||
loading: "Carregando modelos...",
|
||||
loadError: "Não foi possível carregar os modelos locais",
|
||||
downloaded: "Baixado",
|
||||
inUse: "Em uso",
|
||||
recommended: "Recomendado",
|
||||
download: "Baixar",
|
||||
downloading: "Baixando...",
|
||||
downloadingPercent: "Baixando {{percent}}%",
|
||||
extracting: "Extraindo...",
|
||||
delete: "Excluir",
|
||||
deleteAction: "Excluir {{label}}",
|
||||
deleteTitle: "Excluir {{label}}?",
|
||||
deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.",
|
||||
languages: "Idiomas do {{label}}",
|
||||
moreLanguages: "{{languages}} e mais {{count}}",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "Provedores",
|
||||
info: "Modelos e credenciais de cada provedor. Os modelos locais rodam neste host e mostram tamanho e idiomas antes do download.",
|
||||
localStt: "Fala para texto local",
|
||||
localTts: "Texto para fala local",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "{{count}} modelo baixado",
|
||||
downloaded_other: "{{count}} modelos baixados",
|
||||
configured: "Configurado",
|
||||
notConfigured: "Não configurado",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
|
||||
@@ -2139,6 +2139,7 @@ export const ru: TranslationResources = {
|
||||
connections: "Подключения",
|
||||
agents: "Агенты",
|
||||
metadata: "Метаданные",
|
||||
voice: "Голос",
|
||||
workspaces: "Рабочие пространства",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
@@ -2161,6 +2162,77 @@ export const ru: TranslationResources = {
|
||||
docs: "Документация",
|
||||
saveError: "Не удалось обновить настройки генерации метаданных",
|
||||
},
|
||||
voice: {
|
||||
offline: "Подключитесь к этому хосту, чтобы настроить голос.",
|
||||
updateHost: "Обновите хост, чтобы настроить голос.",
|
||||
saveError: "Не удалось обновить голосовые настройки",
|
||||
provider: "Провайдер",
|
||||
providers: {
|
||||
local: "Локально",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "Модель распознавания речи",
|
||||
ttsModel: "Модель голоса",
|
||||
voice: "Голос",
|
||||
notDownloadedOption: "{{label}} (не скачана)",
|
||||
save: "Сохранить",
|
||||
retry: "Повторить",
|
||||
dictation: {
|
||||
title: "Диктовка",
|
||||
enabled: "Диктовка",
|
||||
enabledHint: "Говорите в поле сообщения вместо набора текста",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Голосовой режим",
|
||||
info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.",
|
||||
enabled: "Голосовой режим",
|
||||
enabledHint: "Общайтесь с агентами без рук",
|
||||
sttProvider: "Распознавание речи",
|
||||
ttsProvider: "Синтез речи",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API-ключ",
|
||||
apiKeyHint: "Хранится на хосте и больше не показывается.",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "Новый ключ",
|
||||
removeKey: "Удалить",
|
||||
removeKeyTitle: "Удалить API-ключ OpenAI?",
|
||||
removeKeyMessage:
|
||||
"Функции, настроенные на OpenAI, перестанут работать, пока вы не добавите ключ.",
|
||||
baseUrl: "Базовый URL",
|
||||
baseUrlHint:
|
||||
"Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.",
|
||||
missingKeyWarning: "Выбран OpenAI, но API-ключ не задан.",
|
||||
},
|
||||
models: {
|
||||
loading: "Загрузка моделей...",
|
||||
loadError: "Не удалось загрузить локальные модели",
|
||||
downloaded: "Скачана",
|
||||
inUse: "Используется",
|
||||
recommended: "Рекомендуется",
|
||||
download: "Скачать",
|
||||
downloading: "Скачивание...",
|
||||
downloadingPercent: "Скачивание {{percent}}%",
|
||||
extracting: "Распаковка...",
|
||||
delete: "Удалить",
|
||||
deleteAction: "Удалить {{label}}",
|
||||
deleteTitle: "Удалить {{label}}?",
|
||||
deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.",
|
||||
languages: "Языки {{label}}",
|
||||
moreLanguages: "{{languages}} и ещё {{count}}",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "Провайдеры",
|
||||
info: "Модели и учётные данные каждого провайдера. Локальные модели работают на этом хосте; размер и языки видны до загрузки.",
|
||||
localStt: "Локальное распознавание речи",
|
||||
localTts: "Локальный синтез речи",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "Загружено моделей: {{count}}",
|
||||
downloaded_other: "Загружено моделей: {{count}}",
|
||||
configured: "Настроено",
|
||||
notConfigured: "Не настроено",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
|
||||
@@ -2084,6 +2084,7 @@ export const zhCN: TranslationResources = {
|
||||
connections: "连接",
|
||||
agents: "Agents",
|
||||
metadata: "元数据",
|
||||
voice: "语音",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
@@ -2105,6 +2106,75 @@ export const zhCN: TranslationResources = {
|
||||
docs: "文档",
|
||||
saveError: "无法更新元数据生成设置",
|
||||
},
|
||||
voice: {
|
||||
offline: "连接到此主机以配置语音。",
|
||||
updateHost: "请更新主机以配置语音。",
|
||||
saveError: "无法更新语音设置",
|
||||
provider: "提供方",
|
||||
providers: {
|
||||
local: "本地",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
sttModel: "语音转文字模型",
|
||||
ttsModel: "语音模型",
|
||||
voice: "音色",
|
||||
notDownloadedOption: "{{label}}(未下载)",
|
||||
save: "保存",
|
||||
retry: "重试",
|
||||
dictation: {
|
||||
title: "听写",
|
||||
enabled: "听写",
|
||||
enabledHint: "对着消息框说话,无需打字",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "语音模式",
|
||||
info: "语音模式会转写你说的话,并朗读智能体的回复。",
|
||||
enabled: "语音模式",
|
||||
enabledHint: "免手动与智能体对话",
|
||||
sttProvider: "语音转文字",
|
||||
ttsProvider: "文字转语音",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API 密钥",
|
||||
apiKeyHint: "保存在主机上,不会再次显示。",
|
||||
keyPlaceholder: "sk-...",
|
||||
replaceKeyPlaceholder: "新密钥",
|
||||
removeKey: "移除",
|
||||
removeKeyTitle: "移除 OpenAI API 密钥?",
|
||||
removeKeyMessage: "在添加密钥之前,设为 OpenAI 的功能将无法使用。",
|
||||
baseUrl: "基础 URL",
|
||||
baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。",
|
||||
missingKeyWarning: "已选择 OpenAI,但未设置 API 密钥。",
|
||||
},
|
||||
models: {
|
||||
loading: "正在加载模型...",
|
||||
loadError: "无法加载本地模型",
|
||||
downloaded: "已下载",
|
||||
inUse: "使用中",
|
||||
recommended: "推荐",
|
||||
download: "下载",
|
||||
downloading: "正在下载...",
|
||||
downloadingPercent: "正在下载 {{percent}}%",
|
||||
extracting: "正在解压...",
|
||||
delete: "删除",
|
||||
deleteAction: "删除 {{label}}",
|
||||
deleteTitle: "删除 {{label}}?",
|
||||
deleteMessage: "这将释放 {{size}}。之后可以重新下载。",
|
||||
languages: "{{label}} 支持的语言",
|
||||
moreLanguages: "{{languages}} 等另外 {{count}} 种",
|
||||
},
|
||||
providerSettings: {
|
||||
title: "提供方",
|
||||
info: "各提供方的模型和凭据。本地模型在此主机上运行,下载前会显示大小和支持的语言。",
|
||||
localStt: "本地语音转文字",
|
||||
localTts: "本地文字转语音",
|
||||
openai: "OpenAI",
|
||||
downloaded_one: "已下载 {{count}} 个模型",
|
||||
downloaded_other: "已下载 {{count}} 个模型",
|
||||
configured: "已配置",
|
||||
notConfigured: "未配置",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Code2,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Mic,
|
||||
Blocks,
|
||||
Globe,
|
||||
PanelLeft,
|
||||
@@ -117,6 +118,7 @@ import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -234,6 +236,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
|
||||
{ id: "voice", labelKey: "settings.hostSections.voice", icon: Mic },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
||||
@@ -256,6 +259,8 @@ function renderHostSettingsContent(
|
||||
return <HostAgentsPage serverId={view.serverId} />;
|
||||
case "metadata":
|
||||
return <MetadataGenerationPage serverId={view.serverId} />;
|
||||
case "voice":
|
||||
return <HostVoicePage serverId={view.serverId} />;
|
||||
case "workspaces":
|
||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { isLocalModelBusy } from "./voice-settings";
|
||||
|
||||
const DOWNLOAD_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const;
|
||||
|
||||
export interface LocalSpeechModels {
|
||||
models: LocalSpeechModel[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => void;
|
||||
download: (modelId: string) => Promise<void>;
|
||||
remove: (modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// The daemon reports download progress through the list, so the list polls while any
|
||||
// model is downloading or extracting.
|
||||
export function useLocalSpeechModels(serverId: string): LocalSpeechModels {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const query = useFetchQuery({
|
||||
queryKey: localSpeechModelsQueryKey(serverId),
|
||||
enabled: Boolean(client && isConnected),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 0,
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
return client.listLocalSpeechModels();
|
||||
},
|
||||
refetchInterval: (current) =>
|
||||
current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false,
|
||||
});
|
||||
const { refetch } = query;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const download = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!client) return;
|
||||
await client.downloadLocalSpeechModel(modelId);
|
||||
await refetch();
|
||||
},
|
||||
[client, refetch],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!client) return;
|
||||
await client.deleteLocalSpeechModel(modelId);
|
||||
await refetch();
|
||||
},
|
||||
[client, refetch],
|
||||
);
|
||||
|
||||
return {
|
||||
models: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refresh,
|
||||
download,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert as NativeAlert, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Download, Languages, Trash2 } from "lucide-react-native";
|
||||
import type {
|
||||
LocalSpeechModel,
|
||||
SpeechSettings,
|
||||
SpeechSettingsPatch,
|
||||
} from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCollapsibleRow,
|
||||
SettingsInfoTip,
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsSelect,
|
||||
} from "@/components/settings";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormTextInput } from "@/components/ui/form-field";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useLocalSpeechModels, type LocalSpeechModels } from "./use-local-speech-models";
|
||||
import {
|
||||
SPEECH_PROVIDERS,
|
||||
buildModelOptions,
|
||||
buildOpenAiVoiceOptions,
|
||||
formatLanguages,
|
||||
formatModelSize,
|
||||
getLocalModelState,
|
||||
resolveSelectedModel,
|
||||
resolveSelectedVoice,
|
||||
summarizeLocalModels,
|
||||
toSpeechProvider,
|
||||
usesOpenAi,
|
||||
type SpeechModelKind,
|
||||
type SpeechProvider,
|
||||
} from "./voice-settings";
|
||||
|
||||
type SavePatch = (patch: SpeechSettingsPatch) => void;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function HostVoicePage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "speechSettings");
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const localModels = useLocalSpeechModels(serverId);
|
||||
const { refresh: refreshModels } = localModels;
|
||||
|
||||
const save = useCallback<SavePatch>(
|
||||
(patch) => {
|
||||
patchConfig({ speech: patch })
|
||||
.then(refreshModels)
|
||||
.catch((error: unknown) => {
|
||||
NativeAlert.alert(t("settings.voice.saveError"), errorMessage(error));
|
||||
});
|
||||
},
|
||||
[patchConfig, refreshModels, t],
|
||||
);
|
||||
|
||||
if (!isConnected) {
|
||||
return <Alert size="md" variant="warning" title={t("settings.voice.offline")} />;
|
||||
}
|
||||
if (!supported) {
|
||||
return <Alert size="md" variant="warning" title={t("settings.voice.updateHost")} />;
|
||||
}
|
||||
const speech = config?.speech;
|
||||
if (!speech) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="voice-settings">
|
||||
<DictationSection speech={speech} localModels={localModels.models} save={save} />
|
||||
<VoiceModeSection speech={speech} localModels={localModels.models} save={save} />
|
||||
<ProvidersSection speech={speech} localModels={localModels} save={save} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FeatureSectionProps {
|
||||
speech: SpeechSettings;
|
||||
localModels: LocalSpeechModel[];
|
||||
save: SavePatch;
|
||||
}
|
||||
|
||||
function DictationSection({ speech, localModels, save }: FeatureSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dictation } = speech;
|
||||
const setEnabled = useCallback((enabled: boolean) => save({ dictation: { enabled } }), [save]);
|
||||
const setProvider = useCallback(
|
||||
(provider: SpeechProvider) => save({ dictation: { stt: { provider } } }),
|
||||
[save],
|
||||
);
|
||||
const setModel = useCallback((model: string) => save({ dictation: { stt: { model } } }), [save]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.voice.dictation.title")} testID="voice-dictation">
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
label={t("settings.voice.dictation.enabled")}
|
||||
hint={t("settings.voice.dictation.enabledHint")}
|
||||
>
|
||||
<Switch
|
||||
value={dictation.enabled}
|
||||
onValueChange={setEnabled}
|
||||
accessibilityLabel={t("settings.voice.dictation.enabled")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{dictation.enabled ? (
|
||||
<ProviderRow
|
||||
label={t("settings.voice.provider")}
|
||||
value={toSpeechProvider(dictation.stt.provider)}
|
||||
onChange={setProvider}
|
||||
testID="voice-dictation-provider"
|
||||
/>
|
||||
) : null}
|
||||
{dictation.enabled ? (
|
||||
<ModelRow
|
||||
kind="stt"
|
||||
provider={toSpeechProvider(dictation.stt.provider)}
|
||||
model={dictation.stt.model}
|
||||
localModels={localModels}
|
||||
onChange={setModel}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceModeSection({ speech, localModels, save }: FeatureSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { voiceMode } = speech;
|
||||
const sttProvider = toSpeechProvider(voiceMode.stt.provider);
|
||||
const ttsProvider = toSpeechProvider(voiceMode.tts.provider);
|
||||
const setEnabled = useCallback((enabled: boolean) => save({ voiceMode: { enabled } }), [save]);
|
||||
const setSttProvider = useCallback(
|
||||
(provider: SpeechProvider) => save({ voiceMode: { stt: { provider } } }),
|
||||
[save],
|
||||
);
|
||||
const setSttModel = useCallback(
|
||||
(model: string) => save({ voiceMode: { stt: { model } } }),
|
||||
[save],
|
||||
);
|
||||
const setTtsProvider = useCallback(
|
||||
(provider: SpeechProvider) => save({ voiceMode: { tts: { provider } } }),
|
||||
[save],
|
||||
);
|
||||
const setTtsModel = useCallback(
|
||||
(model: string) => save({ voiceMode: { tts: { model } } }),
|
||||
[save],
|
||||
);
|
||||
const setTtsVoice = useCallback(
|
||||
(voice: string) => save({ voiceMode: { tts: { voice } } }),
|
||||
[save],
|
||||
);
|
||||
const voiceOptions = useMemo(
|
||||
() => buildOpenAiVoiceOptions(resolveSelectedVoice(voiceMode.tts.voice)),
|
||||
[voiceMode.tts.voice],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.voice.voiceMode.title")}
|
||||
info={t("settings.voice.voiceMode.info")}
|
||||
testID="voice-voice-mode"
|
||||
>
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
label={t("settings.voice.voiceMode.enabled")}
|
||||
hint={t("settings.voice.voiceMode.enabledHint")}
|
||||
>
|
||||
<Switch
|
||||
value={voiceMode.enabled}
|
||||
onValueChange={setEnabled}
|
||||
accessibilityLabel={t("settings.voice.voiceMode.enabled")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{voiceMode.enabled ? (
|
||||
<ProviderRow
|
||||
label={t("settings.voice.voiceMode.sttProvider")}
|
||||
value={sttProvider}
|
||||
onChange={setSttProvider}
|
||||
testID="voice-mode-stt-provider"
|
||||
/>
|
||||
) : null}
|
||||
{voiceMode.enabled ? (
|
||||
<ModelRow
|
||||
kind="stt"
|
||||
provider={sttProvider}
|
||||
model={voiceMode.stt.model}
|
||||
localModels={localModels}
|
||||
onChange={setSttModel}
|
||||
/>
|
||||
) : null}
|
||||
{voiceMode.enabled ? (
|
||||
<ProviderRow
|
||||
label={t("settings.voice.voiceMode.ttsProvider")}
|
||||
value={ttsProvider}
|
||||
onChange={setTtsProvider}
|
||||
testID="voice-mode-tts-provider"
|
||||
/>
|
||||
) : null}
|
||||
{voiceMode.enabled ? (
|
||||
<ModelRow
|
||||
kind="tts"
|
||||
provider={ttsProvider}
|
||||
model={voiceMode.tts.model}
|
||||
localModels={localModels}
|
||||
onChange={setTtsModel}
|
||||
/>
|
||||
) : null}
|
||||
{voiceMode.enabled && ttsProvider === "openai" ? (
|
||||
<SettingsSelect
|
||||
label={t("settings.voice.voice")}
|
||||
value={resolveSelectedVoice(voiceMode.tts.voice)}
|
||||
options={voiceOptions}
|
||||
onValueChange={setTtsVoice}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: SpeechProvider;
|
||||
onChange: (provider: SpeechProvider) => void;
|
||||
testID: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SPEECH_PROVIDERS.map((provider) => ({
|
||||
value: provider,
|
||||
label: t(`settings.voice.providers.${provider}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
return (
|
||||
<SettingsSelect
|
||||
label={label}
|
||||
value={value}
|
||||
options={options}
|
||||
onValueChange={onChange}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
kind,
|
||||
provider,
|
||||
model,
|
||||
localModels,
|
||||
onChange,
|
||||
}: {
|
||||
kind: SpeechModelKind;
|
||||
provider: SpeechProvider;
|
||||
model: string | undefined;
|
||||
localModels: LocalSpeechModel[];
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selected = resolveSelectedModel({ kind, provider, model, localModels });
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildModelOptions({
|
||||
kind,
|
||||
provider,
|
||||
selected,
|
||||
localModels,
|
||||
notDownloadedLabel: (label) => t("settings.voice.notDownloadedOption", { label }),
|
||||
}),
|
||||
[kind, localModels, provider, selected, t],
|
||||
);
|
||||
return (
|
||||
<SettingsSelect
|
||||
label={kind === "stt" ? t("settings.voice.sttModel") : t("settings.voice.ttsModel")}
|
||||
value={selected}
|
||||
options={options}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Providers: one collapsible row per provider, each with a one-line summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProvidersSection({
|
||||
speech,
|
||||
localModels,
|
||||
save,
|
||||
}: {
|
||||
speech: SpeechSettings;
|
||||
localModels: LocalSpeechModels;
|
||||
save: SavePatch;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const missingKey = usesOpenAi(speech) && !speech.openai.apiKeyConfigured;
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.voice.providerSettings.title")}
|
||||
info={t("settings.voice.providerSettings.info")}
|
||||
testID="voice-providers"
|
||||
>
|
||||
{missingKey ? (
|
||||
<View style={styles.sectionAlert}>
|
||||
<Alert size="sm" variant="warning" title={t("settings.voice.openai.missingKeyWarning")} />
|
||||
</View>
|
||||
) : null}
|
||||
<SettingsCard>
|
||||
<LocalProviderRow kind="stt" localModels={localModels} />
|
||||
<LocalProviderRow kind="tts" localModels={localModels} />
|
||||
<OpenAiProviderRow speech={speech} save={save} />
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalProviderRow({
|
||||
kind,
|
||||
localModels,
|
||||
}: {
|
||||
kind: SpeechModelKind;
|
||||
localModels: LocalSpeechModels;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { models, isLoading, error, refresh, download, remove } = localModels;
|
||||
const kindModels = useMemo(() => models.filter((model) => model.kind === kind), [kind, models]);
|
||||
const label =
|
||||
kind === "stt"
|
||||
? t("settings.voice.providerSettings.localStt")
|
||||
: t("settings.voice.providerSettings.localTts");
|
||||
|
||||
if (error && kindModels.length === 0) {
|
||||
return (
|
||||
<SettingsCollapsibleRow
|
||||
label={label}
|
||||
summary={t("settings.voice.models.loadError")}
|
||||
testID={`voice-provider-local-${kind}`}
|
||||
>
|
||||
<SettingsRow label={t("settings.voice.models.loadError")} hint={error.message}>
|
||||
<Button variant="outline" onPress={refresh}>
|
||||
{t("settings.voice.retry")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsCollapsibleRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsCollapsibleRow
|
||||
label={label}
|
||||
summary={isLoading ? t("settings.voice.models.loading") : localSummaryLabel(models, kind, t)}
|
||||
testID={`voice-provider-local-${kind}`}
|
||||
>
|
||||
{kindModels.map((model) => (
|
||||
<LocalModelRow key={model.id} model={model} onDownload={download} onDelete={remove} />
|
||||
))}
|
||||
</SettingsCollapsibleRow>
|
||||
);
|
||||
}
|
||||
|
||||
function localSummaryLabel(
|
||||
models: readonly LocalSpeechModel[],
|
||||
kind: SpeechModelKind,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const summary = summarizeLocalModels(models, kind);
|
||||
switch (summary.kind) {
|
||||
case "downloading":
|
||||
return summary.percent === null
|
||||
? t("settings.voice.models.downloading")
|
||||
: t("settings.voice.models.downloadingPercent", { percent: summary.percent });
|
||||
case "extracting":
|
||||
return t("settings.voice.models.extracting");
|
||||
case "downloaded":
|
||||
return t("settings.voice.providerSettings.downloaded", { count: summary.count });
|
||||
}
|
||||
}
|
||||
|
||||
function OpenAiProviderRow({ speech, save }: { speech: SpeechSettings; save: SavePatch }) {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeyConfigured, baseUrl } = speech.openai;
|
||||
const keyInput = useRef<EditingTextInputHandle>(null);
|
||||
const [draftKey, setDraftKey] = useState("");
|
||||
const [draftBaseUrl, setDraftBaseUrl] = useState<string | null>(null);
|
||||
|
||||
const saveKey = useCallback(() => {
|
||||
save({ openai: { apiKey: draftKey } });
|
||||
keyInput.current?.replaceText("");
|
||||
setDraftKey("");
|
||||
}, [draftKey, save]);
|
||||
const removeKey = useCallback(() => {
|
||||
void confirmDialog({
|
||||
title: t("settings.voice.openai.removeKeyTitle"),
|
||||
message: t("settings.voice.openai.removeKeyMessage"),
|
||||
confirmLabel: t("settings.voice.openai.removeKey"),
|
||||
destructive: true,
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) save({ openai: { apiKey: null } });
|
||||
return undefined;
|
||||
});
|
||||
}, [save, t]);
|
||||
const saveBaseUrl = useCallback(() => {
|
||||
if (draftBaseUrl === null) return;
|
||||
save({ openai: { baseUrl: draftBaseUrl.trim() || null } });
|
||||
setDraftBaseUrl(null);
|
||||
}, [draftBaseUrl, save]);
|
||||
|
||||
const apiKeyInfo = useMemo(
|
||||
() => (
|
||||
<SettingsInfoTip
|
||||
title={t("settings.voice.openai.apiKey")}
|
||||
info={t("settings.voice.openai.apiKeyHint")}
|
||||
/>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const baseUrlInfo = useMemo(
|
||||
() => (
|
||||
<SettingsInfoTip
|
||||
title={t("settings.voice.openai.baseUrl")}
|
||||
info={t("settings.voice.openai.baseUrlHint")}
|
||||
/>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCollapsibleRow
|
||||
label={t("settings.voice.providerSettings.openai")}
|
||||
summary={
|
||||
apiKeyConfigured
|
||||
? t("settings.voice.providerSettings.configured")
|
||||
: t("settings.voice.providerSettings.notConfigured")
|
||||
}
|
||||
testID="voice-provider-openai"
|
||||
>
|
||||
<SettingsRow label={t("settings.voice.openai.apiKey")} labelAccessory={apiKeyInfo}>
|
||||
<View style={styles.inputControls}>
|
||||
<FormTextInput
|
||||
ref={keyInput}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={
|
||||
apiKeyConfigured
|
||||
? t("settings.voice.openai.replaceKeyPlaceholder")
|
||||
: t("settings.voice.openai.keyPlaceholder")
|
||||
}
|
||||
onChangeText={setDraftKey}
|
||||
accessibilityLabel={t("settings.voice.openai.apiKey")}
|
||||
style={styles.input}
|
||||
testID="voice-openai-key-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={saveKey}
|
||||
disabled={draftKey.trim().length === 0}
|
||||
testID="voice-openai-key-save"
|
||||
>
|
||||
{t("settings.voice.save")}
|
||||
</Button>
|
||||
{apiKeyConfigured ? (
|
||||
<Button variant="ghost" onPress={removeKey}>
|
||||
{t("settings.voice.openai.removeKey")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t("settings.voice.openai.baseUrl")} labelAccessory={baseUrlInfo}>
|
||||
<View style={styles.inputControls}>
|
||||
<FormTextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
initialValue={baseUrl ?? ""}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
onChangeText={setDraftBaseUrl}
|
||||
accessibilityLabel={t("settings.voice.openai.baseUrl")}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={saveBaseUrl}
|
||||
disabled={draftBaseUrl === null || draftBaseUrl.trim() === (baseUrl ?? "")}
|
||||
>
|
||||
{t("settings.voice.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsRow>
|
||||
</SettingsCollapsibleRow>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalModelRow({
|
||||
model,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
model: LocalSpeechModel;
|
||||
onDownload: (modelId: string) => Promise<void>;
|
||||
onDelete: (modelId: string) => Promise<void>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const state = getLocalModelState(model);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const download = useCallback(() => {
|
||||
setActionError(null);
|
||||
onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error)));
|
||||
}, [model.id, onDownload]);
|
||||
const remove = useCallback(() => {
|
||||
void confirmDialog({
|
||||
title: t("settings.voice.models.deleteTitle", { label: model.label }),
|
||||
message: t("settings.voice.models.deleteMessage", {
|
||||
size: formatModelSize(model.downloadSizeBytes),
|
||||
}),
|
||||
confirmLabel: t("settings.voice.models.delete"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return undefined;
|
||||
setActionError(null);
|
||||
return onDelete(model.id);
|
||||
})
|
||||
.catch((error: unknown) => setActionError(errorMessage(error)));
|
||||
}, [model.downloadSizeBytes, model.id, model.label, onDelete, t]);
|
||||
|
||||
const languages = formatLanguages({
|
||||
languages: model.languages,
|
||||
locale: i18n.language,
|
||||
formatMore: (listed, count) =>
|
||||
t("settings.voice.models.moreLanguages", { languages: listed, count }),
|
||||
});
|
||||
const details = useMemo(
|
||||
() => (
|
||||
<View style={styles.details}>
|
||||
<SettingsInfoTip
|
||||
title={model.label}
|
||||
info={model.description}
|
||||
testID={`voice-model-${model.id}-description`}
|
||||
/>
|
||||
<SettingsInfoTip
|
||||
title={model.label}
|
||||
info={languages}
|
||||
icon={Languages}
|
||||
accessibilityLabel={t("settings.voice.models.languages", { label: model.label })}
|
||||
testID={`voice-model-${model.id}-languages`}
|
||||
/>
|
||||
<Text style={styles.modelSize}>{formatModelSize(model.downloadSizeBytes)}</Text>
|
||||
{state.kind === "installed" ? (
|
||||
<StatusBadge
|
||||
size="xs"
|
||||
variant="success"
|
||||
label={
|
||||
model.inUse ? t("settings.voice.models.inUse") : t("settings.voice.models.downloaded")
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{model.isDefault ? (
|
||||
<StatusBadge size="xs" label={t("settings.voice.models.recommended")} />
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[
|
||||
languages,
|
||||
model.description,
|
||||
model.downloadSizeBytes,
|
||||
model.id,
|
||||
model.inUse,
|
||||
model.isDefault,
|
||||
model.label,
|
||||
state.kind,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
let control = null;
|
||||
if (state.kind === "downloading") {
|
||||
control = (
|
||||
<View style={styles.progress}>
|
||||
<LoadingSpinner size="small" color={styles.spinnerColor.color} />
|
||||
<Text style={styles.progressText}>
|
||||
{state.percent === null
|
||||
? t("settings.voice.models.downloading")
|
||||
: t("settings.voice.models.downloadingPercent", { percent: state.percent })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (state.kind === "extracting") {
|
||||
control = (
|
||||
<View style={styles.progress}>
|
||||
<LoadingSpinner size="small" color={styles.spinnerColor.color} />
|
||||
<Text style={styles.progressText}>{t("settings.voice.models.extracting")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (state.kind === "installed") {
|
||||
control = model.inUse ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={Trash2}
|
||||
onPress={remove}
|
||||
accessibilityLabel={t("settings.voice.models.deleteAction", { label: model.label })}
|
||||
testID={`voice-model-delete-${model.id}`}
|
||||
>
|
||||
{t("settings.voice.models.delete")}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={Download}
|
||||
onPress={download}
|
||||
testID={`voice-model-download-${model.id}`}
|
||||
>
|
||||
{state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
label={model.label}
|
||||
labelAccessory={details}
|
||||
error={actionError ?? (state.kind === "failed" ? state.error : null)}
|
||||
testID={`voice-model-${model.id}`}
|
||||
>
|
||||
{control}
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
loading: {
|
||||
paddingVertical: theme.spacing[8],
|
||||
alignItems: "center",
|
||||
},
|
||||
spinnerColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionAlert: {
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
inputControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
minWidth: 220,
|
||||
},
|
||||
details: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
modelSize: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
progress: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
progressText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import {
|
||||
buildModelOptions,
|
||||
formatLanguages,
|
||||
formatModelSize,
|
||||
getLocalModelState,
|
||||
resolveSelectedModel,
|
||||
summarizeLocalModels,
|
||||
} from "./voice-settings";
|
||||
|
||||
function model(overrides: Partial<LocalSpeechModel>): LocalSpeechModel {
|
||||
return {
|
||||
id: "parakeet-tdt-0.6b-v2-int8",
|
||||
kind: "stt",
|
||||
label: "Parakeet v2",
|
||||
description: "Accurate English transcription.",
|
||||
languages: ["en"],
|
||||
downloadSizeBytes: 482_468_385,
|
||||
isDefault: true,
|
||||
inUse: true,
|
||||
status: "installed",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const catalog = [
|
||||
model({}),
|
||||
model({
|
||||
id: "parakeet-tdt-110m-en-int8",
|
||||
label: "Parakeet Small",
|
||||
isDefault: false,
|
||||
status: "not_installed",
|
||||
inUse: false,
|
||||
}),
|
||||
model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }),
|
||||
];
|
||||
|
||||
describe("voice settings", () => {
|
||||
it("falls back to the catalog default when no local model is saved", () => {
|
||||
expect(
|
||||
resolveSelectedModel({
|
||||
kind: "stt",
|
||||
provider: "local",
|
||||
model: undefined,
|
||||
localModels: catalog,
|
||||
}),
|
||||
).toBe("parakeet-tdt-0.6b-v2-int8");
|
||||
expect(
|
||||
resolveSelectedModel({
|
||||
kind: "tts",
|
||||
provider: "openai",
|
||||
model: undefined,
|
||||
localModels: catalog,
|
||||
}),
|
||||
).toBe("tts-1");
|
||||
});
|
||||
|
||||
it("marks local models that still need a download", () => {
|
||||
expect(
|
||||
buildModelOptions({
|
||||
kind: "stt",
|
||||
provider: "local",
|
||||
selected: "parakeet-tdt-0.6b-v2-int8",
|
||||
localModels: catalog,
|
||||
notDownloadedLabel: (label) => `${label} (not downloaded)`,
|
||||
}),
|
||||
).toEqual([
|
||||
{ value: "parakeet-tdt-0.6b-v2-int8", label: "Parakeet v2" },
|
||||
{ value: "parakeet-tdt-110m-en-int8", label: "Parakeet Small (not downloaded)" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a custom OpenAI-compatible model id selectable", () => {
|
||||
const options = buildModelOptions({
|
||||
kind: "stt",
|
||||
provider: "openai",
|
||||
selected: "my-whisper",
|
||||
localModels: catalog,
|
||||
notDownloadedLabel: (label) => label,
|
||||
});
|
||||
expect(options.at(-1)).toEqual({ value: "my-whisper", label: "my-whisper" });
|
||||
});
|
||||
|
||||
it("reports download progress as a percentage", () => {
|
||||
expect(
|
||||
getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })),
|
||||
).toEqual({ kind: "downloading", percent: 50 });
|
||||
expect(getLocalModelState(model({ status: "downloading" }))).toEqual({
|
||||
kind: "downloading",
|
||||
percent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes each local provider by its downloads", () => {
|
||||
expect(summarizeLocalModels(catalog, "stt")).toEqual({ kind: "downloaded", count: 1 });
|
||||
expect(summarizeLocalModels(catalog, "tts")).toEqual({ kind: "downloaded", count: 1 });
|
||||
const downloading = [
|
||||
...catalog,
|
||||
model({
|
||||
id: "parakeet-tdt-0.6b-v3-int8",
|
||||
status: "downloading",
|
||||
receivedBytes: 1,
|
||||
totalBytes: 4,
|
||||
}),
|
||||
];
|
||||
expect(summarizeLocalModels(downloading, "stt")).toEqual({ kind: "downloading", percent: 25 });
|
||||
expect(summarizeLocalModels(downloading, "tts")).toEqual({ kind: "downloaded", count: 1 });
|
||||
});
|
||||
|
||||
it("formats download sizes", () => {
|
||||
expect(formatModelSize(482_468_385)).toBe("482 MB");
|
||||
expect(formatModelSize(1_250_000_000)).toBe("1.3 GB");
|
||||
});
|
||||
|
||||
it("names a short language list and counts the rest of a long one", () => {
|
||||
const formatMore = (languages: string, count: number) => `${languages} and ${count} more`;
|
||||
expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe(
|
||||
"English, German",
|
||||
);
|
||||
const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"];
|
||||
expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe(
|
||||
"English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { LocalSpeechModel, SpeechSettings } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
|
||||
// What the Voice page needs to know about speech providers and models, kept free of React
|
||||
// so the option lists and model states are testable on their own.
|
||||
|
||||
export type SpeechProvider = "local" | "openai";
|
||||
export type SpeechModelKind = "stt" | "tts";
|
||||
|
||||
export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"];
|
||||
|
||||
// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the
|
||||
// ones the page offers. A saved id outside the list is still shown as an option.
|
||||
const OPENAI_MODELS: Record<SpeechModelKind, { ids: readonly string[]; defaultId: string }> = {
|
||||
stt: {
|
||||
ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"],
|
||||
defaultId: "whisper-1",
|
||||
},
|
||||
tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" },
|
||||
};
|
||||
|
||||
const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"];
|
||||
const DEFAULT_OPENAI_VOICE = "alloy";
|
||||
|
||||
export interface SpeechModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function toSpeechProvider(provider: string): SpeechProvider {
|
||||
return provider === "openai" ? "openai" : "local";
|
||||
}
|
||||
|
||||
export function resolveSelectedModel(input: {
|
||||
kind: SpeechModelKind;
|
||||
provider: SpeechProvider;
|
||||
model: string | undefined;
|
||||
localModels: readonly LocalSpeechModel[];
|
||||
}): string {
|
||||
if (input.model) return input.model;
|
||||
if (input.provider === "openai") return OPENAI_MODELS[input.kind].defaultId;
|
||||
return input.localModels.find((m) => m.kind === input.kind && m.isDefault)?.id ?? "";
|
||||
}
|
||||
|
||||
export function buildModelOptions(input: {
|
||||
kind: SpeechModelKind;
|
||||
provider: SpeechProvider;
|
||||
selected: string;
|
||||
localModels: readonly LocalSpeechModel[];
|
||||
notDownloadedLabel: (label: string) => string;
|
||||
}): SpeechModelOption[] {
|
||||
if (input.provider === "openai") {
|
||||
return withSelected(
|
||||
OPENAI_MODELS[input.kind].ids.map((id) => ({ value: id, label: id })),
|
||||
input.selected,
|
||||
);
|
||||
}
|
||||
return input.localModels
|
||||
.filter((model) => model.kind === input.kind)
|
||||
.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.status === "installed" ? model.label : input.notDownloadedLabel(model.label),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveSelectedVoice(voice: string | undefined): string {
|
||||
return voice ?? DEFAULT_OPENAI_VOICE;
|
||||
}
|
||||
|
||||
export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] {
|
||||
return withSelected(
|
||||
OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })),
|
||||
selected,
|
||||
);
|
||||
}
|
||||
|
||||
function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] {
|
||||
if (!selected || options.some((option) => option.value === selected)) return options;
|
||||
return [...options, { value: selected, label: selected }];
|
||||
}
|
||||
|
||||
export function usesOpenAi(settings: SpeechSettings): boolean {
|
||||
return (
|
||||
(settings.dictation.enabled && settings.dictation.stt.provider === "openai") ||
|
||||
(settings.voiceMode.enabled &&
|
||||
(settings.voiceMode.stt.provider === "openai" ||
|
||||
settings.voiceMode.tts.provider === "openai"))
|
||||
);
|
||||
}
|
||||
|
||||
export type LocalModelState =
|
||||
| { kind: "installed" }
|
||||
| { kind: "not_installed" }
|
||||
| { kind: "downloading"; percent: number | null }
|
||||
| { kind: "extracting" }
|
||||
| { kind: "failed"; error: string };
|
||||
|
||||
export function getLocalModelState(model: LocalSpeechModel): LocalModelState {
|
||||
switch (model.status) {
|
||||
case "installed":
|
||||
return { kind: "installed" };
|
||||
case "downloading": {
|
||||
const total = model.totalBytes ?? 0;
|
||||
const received = model.receivedBytes ?? 0;
|
||||
return {
|
||||
kind: "downloading",
|
||||
percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null,
|
||||
};
|
||||
}
|
||||
case "extracting":
|
||||
return { kind: "extracting" };
|
||||
case "failed":
|
||||
return { kind: "failed", error: model.error ?? "" };
|
||||
default:
|
||||
return { kind: "not_installed" };
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalModelBusy(model: LocalSpeechModel): boolean {
|
||||
return model.status === "downloading" || model.status === "extracting";
|
||||
}
|
||||
|
||||
// What a collapsed local provider row says: an active download wins over the count.
|
||||
export type LocalModelsSummary =
|
||||
| { kind: "downloading"; percent: number | null }
|
||||
| { kind: "extracting" }
|
||||
| { kind: "downloaded"; count: number };
|
||||
|
||||
export function summarizeLocalModels(
|
||||
models: readonly LocalSpeechModel[],
|
||||
kind: SpeechModelKind,
|
||||
): LocalModelsSummary {
|
||||
const kindModels = models.filter((model) => model.kind === kind);
|
||||
const busy = kindModels.find(isLocalModelBusy);
|
||||
if (busy) {
|
||||
const state = getLocalModelState(busy);
|
||||
if (state.kind === "downloading") return state;
|
||||
return { kind: "extracting" };
|
||||
}
|
||||
return {
|
||||
kind: "downloaded",
|
||||
count: kindModels.filter((model) => model.status === "installed").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatModelSize(bytes: number): string {
|
||||
const megabytes = bytes / 1_000_000;
|
||||
if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`;
|
||||
return `${Math.round(megabytes)} MB`;
|
||||
}
|
||||
|
||||
// Multilingual models list close to a hundred languages; past this many the tooltip names
|
||||
// the first ones and counts the rest.
|
||||
const MAX_LISTED_LANGUAGES = 10;
|
||||
|
||||
export function formatLanguages(input: {
|
||||
languages: readonly string[];
|
||||
locale: string;
|
||||
formatMore: (languages: string, count: number) => string;
|
||||
}): string {
|
||||
const names = languageNames(input.locale);
|
||||
const listed = input.languages
|
||||
.slice(0, MAX_LISTED_LANGUAGES)
|
||||
.map((code) => names?.of(code) ?? code.toUpperCase())
|
||||
.join(", ");
|
||||
const hidden = input.languages.length - MAX_LISTED_LANGUAGES;
|
||||
return hidden > 0 ? input.formatMore(listed, hidden) : listed;
|
||||
}
|
||||
|
||||
// Hermes ships without Intl.DisplayNames; the language code is the fallback label there.
|
||||
function languageNames(locale: string): Intl.DisplayNames | null {
|
||||
if (typeof Intl.DisplayNames !== "function") return null;
|
||||
return new Intl.DisplayNames([locale], { type: "language" });
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"pair-device",
|
||||
"agents",
|
||||
"metadata",
|
||||
"voice",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"usage",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export default defineConfig({
|
||||
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
|
||||
include: [
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
],
|
||||
@@ -91,6 +92,8 @@ export default defineConfig({
|
||||
"react-native-keyboard-controller",
|
||||
],
|
||||
},
|
||||
// babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React.
|
||||
esbuild: { jsx: "automatic" },
|
||||
// The globals a React Native bundler defines, which esbuild is no longer there to supply for
|
||||
// the package excluded above.
|
||||
define: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CreationClient } from "./creation/index.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
@@ -5264,6 +5265,34 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listLocalSpeechModels(requestId?: string): Promise<LocalSpeechModel[]> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "speech.local_models.list.request" },
|
||||
responseType: "speech.local_models.list.response",
|
||||
});
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
return payload.models;
|
||||
}
|
||||
|
||||
async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise<void> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "speech.local_models.download.request", modelId },
|
||||
responseType: "speech.local_models.download.response",
|
||||
});
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
}
|
||||
|
||||
async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise<void> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "speech.local_models.delete.request", modelId },
|
||||
responseType: "speech.local_models.delete.response",
|
||||
});
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
}
|
||||
|
||||
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
|
||||
this.sendSessionMessageStrict(response);
|
||||
}
|
||||
|
||||
@@ -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,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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,16 @@ import {
|
||||
ScheduleRunOnceResponseSchema,
|
||||
ScheduleUpdateResponseSchema,
|
||||
} from "./schedule/rpc-schemas.js";
|
||||
import {
|
||||
SpeechLocalModelsDeleteRequestSchema,
|
||||
SpeechLocalModelsDeleteResponseSchema,
|
||||
SpeechLocalModelsDownloadRequestSchema,
|
||||
SpeechLocalModelsDownloadResponseSchema,
|
||||
SpeechLocalModelsListRequestSchema,
|
||||
SpeechLocalModelsListResponseSchema,
|
||||
SpeechSettingsPatchSchema,
|
||||
SpeechSettingsSchema,
|
||||
} from "./speech/rpc-schemas.js";
|
||||
import {
|
||||
LoopRunRequestSchema,
|
||||
LoopListRequestSchema,
|
||||
@@ -208,6 +218,8 @@ export const MutableDaemonConfigSchema = z
|
||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
// COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it.
|
||||
speech: SpeechSettingsSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -228,6 +240,7 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
speech: SpeechSettingsPatchSchema.optional(),
|
||||
})
|
||||
.partial()
|
||||
.passthrough();
|
||||
@@ -3390,6 +3403,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ChatWaitRequestSchema,
|
||||
ScheduleCreateRequestSchema,
|
||||
ScheduleListRequestSchema,
|
||||
SpeechLocalModelsListRequestSchema,
|
||||
SpeechLocalModelsDownloadRequestSchema,
|
||||
SpeechLocalModelsDeleteRequestSchema,
|
||||
ScheduleInspectRequestSchema,
|
||||
ScheduleLogsRequestSchema,
|
||||
SchedulePauseRequestSchema,
|
||||
@@ -3620,6 +3636,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
|
||||
daemonConfigReload: z.boolean().optional(),
|
||||
// COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18.
|
||||
speechSettings: z.boolean().optional(),
|
||||
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
|
||||
relayConfig: z.boolean().optional(),
|
||||
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
|
||||
@@ -6306,14 +6324,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 +6344,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({
|
||||
@@ -7055,6 +7079,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ChatWaitResponseSchema,
|
||||
ScheduleCreateResponseSchema,
|
||||
ScheduleListResponseSchema,
|
||||
SpeechLocalModelsListResponseSchema,
|
||||
SpeechLocalModelsDownloadResponseSchema,
|
||||
SpeechLocalModelsDeleteResponseSchema,
|
||||
ScheduleInspectResponseSchema,
|
||||
ScheduleLogsResponseSchema,
|
||||
SchedulePauseResponseSchema,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Speech settings: the `speech` section of the mutable daemon config.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Provider ids stay plain strings so an old client can parse a daemon that adds one.
|
||||
const SpeechSttSettingsSchema = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
// Omitted means the provider's default model.
|
||||
model: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const SpeechTtsSettingsSchema = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
model: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const SpeechSettingsSchema = z
|
||||
.object({
|
||||
dictation: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
stt: SpeechSttSettingsSchema,
|
||||
})
|
||||
.passthrough(),
|
||||
voiceMode: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
stt: SpeechSttSettingsSchema,
|
||||
tts: SpeechTtsSettingsSchema,
|
||||
})
|
||||
.passthrough(),
|
||||
openai: z
|
||||
.object({
|
||||
// The key itself is write-only; readers only learn whether one is set.
|
||||
apiKeyConfigured: z.boolean(),
|
||||
baseUrl: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// null clears a field back to its default. Changing `provider` without `model` clears
|
||||
// the model, because model ids belong to one provider.
|
||||
const SpeechSttSettingsPatchSchema = z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
language: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const SpeechTtsSettingsPatchSchema = z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
voice: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const SpeechSettingsPatchSchema = z
|
||||
.object({
|
||||
dictation: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stt: SpeechSttSettingsPatchSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
voiceMode: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stt: SpeechSttSettingsPatchSchema.optional(),
|
||||
tts: SpeechTtsSettingsPatchSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
openai: z
|
||||
.object({
|
||||
apiKey: z.string().nullable().optional(),
|
||||
baseUrl: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type SpeechSettings = z.infer<typeof SpeechSettingsSchema>;
|
||||
export type SpeechSettingsPatch = z.infer<typeof SpeechSettingsPatchSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local speech models: catalog, install state, download, delete.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LocalSpeechModelSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
// "stt" or "tts".
|
||||
kind: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string(),
|
||||
languages: z.array(z.string()),
|
||||
downloadSizeBytes: z.number(),
|
||||
isDefault: z.boolean(),
|
||||
// Selected by an enabled feature; the daemon keeps it installed.
|
||||
inUse: z.boolean(),
|
||||
// "installed" | "not_installed" | "downloading" | "extracting" | "failed".
|
||||
status: z.string(),
|
||||
receivedBytes: z.number().optional(),
|
||||
totalBytes: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type LocalSpeechModel = z.infer<typeof LocalSpeechModelSchema>;
|
||||
|
||||
export const SpeechLocalModelsListRequestSchema = z.object({
|
||||
type: z.literal("speech.local_models.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechLocalModelsDownloadRequestSchema = z.object({
|
||||
type: z.literal("speech.local_models.download.request"),
|
||||
requestId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechLocalModelsDeleteRequestSchema = z.object({
|
||||
type: z.literal("speech.local_models.delete.request"),
|
||||
requestId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechLocalModelsListResponseSchema = z.object({
|
||||
type: z.literal("speech.local_models.list.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
models: z.array(LocalSpeechModelSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Resolves once the download starts; poll the list for progress.
|
||||
export const SpeechLocalModelsDownloadResponseSchema = z.object({
|
||||
type: z.literal("speech.local_models.download.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechLocalModelsDeleteResponseSchema = z.object({
|
||||
type: z.literal("speech.local_models.delete.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -174,6 +174,9 @@ const INBOUND_PERMISSION = {
|
||||
set_agent_model_request: "workspace.write",
|
||||
set_agent_thinking_request: "workspace.write",
|
||||
set_daemon_config_request: "daemon.manage",
|
||||
"speech.local_models.list.request": "daemon.read",
|
||||
"speech.local_models.download.request": "daemon.manage",
|
||||
"speech.local_models.delete.request": "daemon.manage",
|
||||
set_voice_mode: "workspace.write",
|
||||
shutdown_server_request: "daemon.manage",
|
||||
start_workspace_script_request: "workspace.write",
|
||||
@@ -399,6 +402,9 @@ const OUTBOUND_PERMISSION = {
|
||||
set_agent_model_response: "workspace.write",
|
||||
set_agent_thinking_response: "workspace.write",
|
||||
set_daemon_config_response: "daemon.manage",
|
||||
"speech.local_models.list.response": "daemon.read",
|
||||
"speech.local_models.download.response": "daemon.manage",
|
||||
"speech.local_models.delete.response": "daemon.manage",
|
||||
set_voice_mode_response: "workspace.write",
|
||||
start_workspace_script_response: "workspace.write",
|
||||
stash_list_response: "workspace.read",
|
||||
|
||||
@@ -131,6 +131,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { createSpeechSettingsOwner } from "./speech/speech-settings.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
@@ -370,6 +371,8 @@ export interface PaseoSpeechSttLanguages {
|
||||
|
||||
export interface PaseoSpeechConfig {
|
||||
providers: RequestedSpeechProviders;
|
||||
// Where local models live, whether or not a feature currently uses the local provider.
|
||||
localModelsDir: string;
|
||||
sttLanguages?: PaseoSpeechSttLanguages;
|
||||
local?: PaseoLocalSpeechConfig;
|
||||
}
|
||||
@@ -537,6 +540,10 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
|
||||
return config.trustedProxies ?? ["loopback"];
|
||||
}
|
||||
|
||||
function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
|
||||
return config.configReload?.env ?? process.env;
|
||||
}
|
||||
|
||||
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
|
||||
const providers = config.providerOverrides ?? {};
|
||||
|
||||
@@ -596,13 +603,24 @@ export async function createPaseoDaemon(
|
||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
|
||||
const initialMutableConfig = createInitialMutableDaemonConfig(config);
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, {
|
||||
relayEnabledMutable: config.relayEnabledMutable ?? true,
|
||||
startupPersisted: config.configReload?.startupPersisted,
|
||||
speech: createSpeechSettingsOwner({
|
||||
paseoHome: config.paseoHome,
|
||||
env: resolveReloadEnv(config),
|
||||
speechService,
|
||||
}),
|
||||
reloadSource: {
|
||||
resolve: (persisted) => {
|
||||
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
|
||||
env: config.configReload?.env ?? process.env,
|
||||
env: resolveReloadEnv(config),
|
||||
cli: config.configReload?.cli,
|
||||
relayEnabledFallback: config.configReload?.relayEnabledFallback,
|
||||
});
|
||||
@@ -1591,13 +1609,6 @@ export async function createPaseoDaemon(
|
||||
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
|
||||
}
|
||||
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
const start = async () => {
|
||||
|
||||
@@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async ()
|
||||
expect(voice).toBeTruthy();
|
||||
|
||||
expect(voice?.dictation.enabled).toBe(false);
|
||||
expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice.");
|
||||
expect(voice?.voice.enabled).toBe(false);
|
||||
expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
|
||||
expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice.");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await isolatedCtx.cleanup();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -1193,3 +1194,110 @@ describe("DaemonConfigStore reload", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DaemonConfigStore speech settings", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createSpeechStore(initialPersisted?: PersistedConfig) {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-"));
|
||||
tempDirs.push(paseoHome);
|
||||
if (initialPersisted) {
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify(initialPersisted, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const applied: PersistedConfig[] = [];
|
||||
const speech = {
|
||||
read: (next: PersistedConfig) => readSpeechSettings(next, {}),
|
||||
apply: (next: PersistedConfig) => applied.push(next),
|
||||
};
|
||||
const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, {
|
||||
speech,
|
||||
reloadSource: {
|
||||
resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }),
|
||||
},
|
||||
});
|
||||
return { paseoHome, store, applied, persisted };
|
||||
}
|
||||
|
||||
test("keeps voice mode off until it is turned on", () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
|
||||
expect(store.get().speech?.dictation.enabled).toBe(true);
|
||||
expect(store.get().speech?.voiceMode.enabled).toBe(false);
|
||||
|
||||
const result = store.patch({ speech: { voiceMode: { enabled: true } } });
|
||||
|
||||
expect(result.speech?.voiceMode.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("stores the OpenAI key without ever returning it", () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
const result = store.patch({
|
||||
speech: { dictation: { stt: { provider: "openai" } }, openai: { apiKey: " sk-test " } },
|
||||
});
|
||||
|
||||
expect(result.speech?.openai).toEqual({ apiKeyConfigured: true });
|
||||
expect(JSON.stringify(result)).not.toContain("sk-test");
|
||||
expect(loadPersistedConfig(paseoHome).providers?.openai?.apiKey).toBe("sk-test");
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("applies a replaced key even though the visible settings do not change", () => {
|
||||
const { store, applied } = createSpeechStore({
|
||||
version: 1,
|
||||
providers: { openai: { apiKey: "sk-old" } },
|
||||
});
|
||||
|
||||
store.patch({ speech: { openai: { apiKey: "sk-new" } } });
|
||||
|
||||
expect(applied.map((next) => next.providers?.openai?.apiKey)).toEqual(["sk-new"]);
|
||||
});
|
||||
|
||||
test("switching provider clears the previous provider's model", () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
|
||||
});
|
||||
|
||||
const result = store.patch({ speech: { dictation: { stt: { provider: "openai" } } } });
|
||||
|
||||
expect(result.speech?.dictation.stt).toEqual({ provider: "openai" });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a local model id outside the catalog without persisting it", () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
expect(() =>
|
||||
store.patch({ speech: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
|
||||
).toThrow("Unknown local voice model: nope");
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
|
||||
expect(applied).toEqual([]);
|
||||
});
|
||||
|
||||
test("reload applies hand-edited speech settings without a restart", () => {
|
||||
const { paseoHome, store, applied, persisted } = createSpeechStore();
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const result = store.reload();
|
||||
|
||||
expect(result.appliedPaths).toContain("features.voiceMode");
|
||||
expect(result.restartRequiredPaths).toEqual([]);
|
||||
expect(store.get().speech?.voiceMode.enabled).toBe(false);
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
MutableDaemonConfigPatchSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js";
|
||||
|
||||
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -31,8 +33,11 @@ interface SupportedMutableConfigPatch {
|
||||
skills?: MutableDaemonConfig["skills"];
|
||||
pluginsEnabled?: boolean;
|
||||
plugins?: MutableDaemonConfig["plugins"];
|
||||
speech?: SpeechSettingsPatch;
|
||||
}
|
||||
|
||||
const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode", "providers.openai"];
|
||||
|
||||
interface LoggerLike {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
info(...args: unknown[]): void;
|
||||
@@ -276,6 +281,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
||||
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
|
||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||
...(patch.speech !== undefined ? { speech: patch.speech } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,6 +324,7 @@ export class DaemonConfigStore {
|
||||
private readonly relayEnabledMutable: boolean;
|
||||
private readonly reloadSource: DaemonConfigReloadSource | undefined;
|
||||
private readonly startupPersisted: PersistedConfig;
|
||||
private readonly speechOwner: SpeechSettingsOwner | undefined;
|
||||
private lastKnownPersisted: PersistedConfig;
|
||||
|
||||
constructor(
|
||||
@@ -328,18 +335,21 @@ export class DaemonConfigStore {
|
||||
relayEnabledMutable?: boolean;
|
||||
reloadSource?: DaemonConfigReloadSource;
|
||||
startupPersisted?: PersistedConfig;
|
||||
speech?: SpeechSettingsOwner;
|
||||
} = {},
|
||||
) {
|
||||
this.paseoHome = paseoHome;
|
||||
this.logger = getLogger(logger);
|
||||
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
|
||||
this.reloadSource = options.reloadSource;
|
||||
this.speechOwner = options.speech;
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
this.current = MutableDaemonConfigSchema.parse({
|
||||
...initial,
|
||||
relay: initial.relay ?? { enabled: true },
|
||||
...this.readSpeech(this.startupPersisted),
|
||||
});
|
||||
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
|
||||
this.reloadSource = options.reloadSource;
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
}
|
||||
|
||||
public get(): MutableDaemonConfig {
|
||||
@@ -361,9 +371,11 @@ export class DaemonConfigStore {
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
);
|
||||
}
|
||||
const { removeProviders = [], ...configPatch } = parsedPatch;
|
||||
const { removeProviders = [], ...patch } = parsedPatch;
|
||||
const { speech: speechPatch, ...configPatch } = patch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
if (speechPatch !== undefined) merged.speech = this.readPatchedSpeech(speechPatch);
|
||||
if (parsedPatch.skills?.selection !== undefined) {
|
||||
merged.skills = { selection: parsedPatch.skills.selection };
|
||||
}
|
||||
@@ -377,30 +389,47 @@ export class DaemonConfigStore {
|
||||
|
||||
const configChanged = !isEqualValue(this.current, next);
|
||||
|
||||
if (!configChanged && removedProviders.length === 0) {
|
||||
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
||||
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
|
||||
configPatch,
|
||||
patch,
|
||||
removedProviders,
|
||||
);
|
||||
if (!configChanged) {
|
||||
this.lastKnownPersisted = knownNext;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
this.lastKnownPersisted = knownNext;
|
||||
} catch (error) {
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext);
|
||||
if (configChanged) {
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
} catch (error) {
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.lastKnownPersisted = knownNext;
|
||||
if (speechChanged) this.speechOwner?.apply(knownNext);
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
private readSpeech(persisted: PersistedConfig): Pick<MutableDaemonConfig, "speech"> {
|
||||
return this.speechOwner ? { speech: this.speechOwner.read(persisted) } : {};
|
||||
}
|
||||
|
||||
private readPatchedSpeech(speechPatch: SpeechSettingsPatch): SpeechSettings {
|
||||
if (!this.speechOwner) {
|
||||
throw new Error("Speech settings are unavailable for this daemon instance");
|
||||
}
|
||||
return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch));
|
||||
}
|
||||
|
||||
private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean {
|
||||
return diffPaths(previous, next).some((path) =>
|
||||
SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)),
|
||||
);
|
||||
}
|
||||
|
||||
public reload(): DaemonConfigReloadResult {
|
||||
if (!this.reloadSource) {
|
||||
throw new Error("Daemon config reload is unavailable for this daemon instance");
|
||||
@@ -413,6 +442,7 @@ export class DaemonConfigStore {
|
||||
const desired = MutableDaemonConfigSchema.parse({
|
||||
...resolved.mutable,
|
||||
plugins: this.current.plugins,
|
||||
...this.readSpeech(persisted),
|
||||
});
|
||||
const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted);
|
||||
const overrideControlledPaths = compactOwnedPaths(
|
||||
@@ -421,7 +451,7 @@ export class DaemonConfigStore {
|
||||
),
|
||||
resolved.overrideControlledPaths,
|
||||
);
|
||||
const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => {
|
||||
const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => {
|
||||
if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) {
|
||||
return false;
|
||||
}
|
||||
@@ -438,6 +468,8 @@ export class DaemonConfigStore {
|
||||
diffPaths(this.startupPersisted, persisted).filter((path) => {
|
||||
if (path === "$schema" || path === "version") return false;
|
||||
if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false;
|
||||
if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)))
|
||||
return false;
|
||||
return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner));
|
||||
}),
|
||||
[],
|
||||
@@ -446,8 +478,17 @@ export class DaemonConfigStore {
|
||||
const removedProviders = Object.keys(this.current.providers).filter(
|
||||
(provider) => !(provider in desired.providers),
|
||||
);
|
||||
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted);
|
||||
this.applyReplacement(desired, { removedProviders });
|
||||
this.lastKnownPersisted = persisted;
|
||||
if (speechChanged && this.speechOwner) {
|
||||
this.speechOwner.apply(persisted);
|
||||
appliedPaths.push(
|
||||
...SPEECH_PERSISTED_PATHS.filter((owner) =>
|
||||
changedSinceLastApply.some((path) => pathBelongsTo(path, owner)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
appliedPaths: [...appliedPaths].sort(),
|
||||
@@ -584,8 +625,9 @@ function mergeMutablePatchIntoPersistedConfig(params: {
|
||||
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
|
||||
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
|
||||
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
|
||||
const withSpeech = patch.speech ? mergeSpeechSettingsPatch(persisted, patch.speech) : persisted;
|
||||
return {
|
||||
...persisted,
|
||||
...withSpeech,
|
||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||
...(daemon ? { daemon } : { daemon: undefined }),
|
||||
|
||||
+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