mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a1e60563 | ||
|
|
5ce016bb1f | ||
|
|
a5c566f807 | ||
|
|
9521ee9955 | ||
|
|
bd5061e7bd | ||
|
|
df7f1f0b75 | ||
|
|
cf62b00911 | ||
|
|
efd6d95f91 | ||
|
|
1a6a9fa8d0 | ||
|
|
a7a21d91c2 | ||
|
|
b2da58b5bf | ||
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 | ||
|
|
c517d07d6f | ||
|
|
6388c466b1 | ||
|
|
3c48ac3ad2 | ||
|
|
a4eb0adcf6 | ||
|
|
6ff071a487 | ||
|
|
ecd42015b9 | ||
|
|
86817a24fc | ||
|
|
86ab49153a | ||
|
|
6e5a4682ee | ||
|
|
f22775a8e1 | ||
|
|
a0aeca967c | ||
|
|
5384abdbaa | ||
|
|
06cdc5a0b8 | ||
|
|
4a32f95848 | ||
|
|
1b450e487e | ||
|
|
74c8172f8b | ||
|
|
fa70b215a8 | ||
|
|
30799fda19 | ||
|
|
6e1ec21c6a | ||
|
|
f472c9d1f4 | ||
|
|
ec9817ed27 | ||
|
|
8555e8abd9 | ||
|
|
88a7aec1b8 | ||
|
|
cd77ecd837 | ||
|
|
30122b1ae1 | ||
|
|
d152f1529f | ||
|
|
05b3745dcc |
+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)
|
||||
|
||||
+16
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.1 - 2026-10-16
|
||||
## 0.12.0-beta.2 - 2026-10-17
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
|
||||
@@ -27,8 +27,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
|
||||
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
|
||||
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
|
||||
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
|
||||
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
|
||||
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
|
||||
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
|
||||
|
||||
### Plugins
|
||||
@@ -37,6 +40,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
|
||||
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
|
||||
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -51,6 +55,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
|
||||
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
|
||||
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
|
||||
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
|
||||
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
@@ -59,7 +64,12 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
|
||||
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
|
||||
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
|
||||
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
|
||||
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
|
||||
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
|
||||
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
|
||||
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
|
||||
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
|
||||
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
@@ -85,9 +95,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
|
||||
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
|
||||
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
|
||||
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
|
||||
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
|
||||
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
|
||||
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
|
||||
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
|
||||
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
@@ -108,6 +122,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
|
||||
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
|
||||
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
|
||||
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
|
||||
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
|
||||
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
|
||||
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
|
||||
|
||||
+3
-3
@@ -205,12 +205,12 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
---
|
||||
|
||||
@@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+22
-5
@@ -300,16 +300,33 @@ done
|
||||
|
||||
### Android audio focus interruptions
|
||||
|
||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
|
||||
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
|
||||
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
|
||||
Run the microphone-privacy cases on Android 12 or newer.
|
||||
|
||||
Verify these distinct outcomes:
|
||||
|
||||
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
|
||||
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
|
||||
access resumes it. Starting dictation while access is disabled waits for the same recovery.
|
||||
- Returning from music controls resumes dictation without pausing music again. Cancelling a
|
||||
backgrounded recording must prevent the lifecycle callback from restarting it.
|
||||
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
|
||||
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
|
||||
|
||||
```bash
|
||||
adb shell am start -n sh.paseo/.MainActivity
|
||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||
# Start voice mode in an existing composer, then background Paseo.
|
||||
adb emu gsm call 5551234
|
||||
# Foreground Paseo while the call is still ringing.
|
||||
# Foreground Paseo while the call is still ringing, then end the simulated call.
|
||||
adb emu gsm cancel 5551234
|
||||
```
|
||||
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
|
||||
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
|
||||
Use browser/device automation to operate the app; the commands above only control emulator telephony.
|
||||
|
||||
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
|
||||
|
||||
### Releasing the audio session when idle
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
+13
-10
@@ -305,12 +305,14 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
|
||||
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
|
||||
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
|
||||
|
||||
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
@@ -334,7 +336,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Inspect the full release workflow, including submit_ios, submit_android,
|
||||
# Inspect the full release workflow, including distribute_ios, submit_android,
|
||||
# and submit_ios_for_review.
|
||||
npx eas workflow:view <workflow-run-id> --json
|
||||
|
||||
@@ -347,17 +349,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
|
||||
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
|
||||
For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
|
||||
- `build_ios` — iOS binary built
|
||||
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
|
||||
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
@@ -380,8 +382,9 @@ Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
For stable releases, also confirm every required mobile build, external
|
||||
TestFlight distribution, Beta App Review submission, and store submission job
|
||||
for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
@@ -396,7 +399,7 @@ Pattern:
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -680,7 +683,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
|
||||
@@ -251,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-Sw9WSOnvebpXgty0WACJrRq+pkKuynLMxJg6D/SnyRk=
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36349,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37469,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37721,10 +37721,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37979,7 +37979,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38904,14 +38904,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38921,8 +38921,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38944,7 +38944,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/highlight": "0.12.0-beta.1",
|
||||
"@getpaseo/plugin": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/highlight": "0.12.0-beta.2",
|
||||
"@getpaseo/plugin": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40119,7 +40119,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41727,7 +41727,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -23,13 +23,16 @@ jobs:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit_ios:
|
||||
name: Submit iOS
|
||||
distribute_ios:
|
||||
name: Distribute iOS to TestFlight
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
type: testflight
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
profile: production
|
||||
external_groups:
|
||||
- Paseo Beta
|
||||
submit_beta_review: true
|
||||
|
||||
submit_android:
|
||||
name: Submit Android
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, submit_ios]
|
||||
needs: [build_ios, distribute_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1296.0)
|
||||
aws-sdk-core (3.257.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
rexml (~> 3.4, >= 3.4.2)
|
||||
aws-sdk-kms (1.132.0)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.233.2)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.7.20261007)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.3)
|
||||
logger
|
||||
faraday (2.15.0)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.5.0)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.241.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
erb (>= 4.0, < 7.0)
|
||||
excon (>= 1.0.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.5.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 3.1.0, < 4.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.109.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.3.1)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.69.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.4.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.1)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.1)
|
||||
googleauth (1.17.5)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.2)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.10.0)
|
||||
net-protocol (>= 0.3.0)
|
||||
uri (>= 0.11.1)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.3.1)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.1)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (3.1.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.3.0)
|
||||
unicode-emoji (~> 4.3)
|
||||
unicode-emoji (4.3.0)
|
||||
uri (1.1.2)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (~> 2.234)
|
||||
multi_json
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
|
||||
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
|
||||
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
|
||||
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
||||
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
|
||||
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
|
||||
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
|
||||
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
|
||||
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
|
||||
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
|
||||
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
|
||||
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
||||
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
||||
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.23
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
title: "Screenshot file links",
|
||||
initialPrompt: "Show screenshots",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
|
||||
await openAgentRoute(page, session);
|
||||
await test.step("Open the linked screenshot", async () => {
|
||||
await openAssistantMarkdownLink(page, "Capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
|
||||
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
|
||||
await expect(page.getByText(/ENOENT/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function openMarkdownPreview(page: Page) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-bar")
|
||||
.getByRole("button", { name: "Preview", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
async function followPreviewLink(page: Page, name: string) {
|
||||
await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByRole("link", { name, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Markdown preview folder links reveal Files and file links still open a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guides/guide.md"),
|
||||
"# Folder link destination\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "README.md"),
|
||||
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Guide");
|
||||
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Dot");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Bare");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
|
||||
});
|
||||
|
||||
async function openReportedFolderLink(page: Page, name: string) {
|
||||
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
|
||||
await link.click();
|
||||
}
|
||||
|
||||
async function expectFolderContents(page: Page) {
|
||||
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const folder of [
|
||||
"./docs/guides",
|
||||
"./docs/guides/",
|
||||
"./docs/reports.v1",
|
||||
"./.github/workflows",
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "folder-link-repro-",
|
||||
title: "Folder link reproduction",
|
||||
initialPrompt: "Show the folder link",
|
||||
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await openReportedFolderLink(page, "Open folder");
|
||||
await expectFolderContents(page);
|
||||
} finally {
|
||||
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
@@ -126,6 +124,7 @@ test.describe("Tab creation", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
@@ -190,11 +189,18 @@ test.describe("Tab creation", () => {
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -202,12 +208,17 @@ test.describe("Tab creation", () => {
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -295,13 +306,6 @@ test.describe("Tab creation", () => {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
|
||||
async function openProjectHome(page: Page) {
|
||||
await page.goto("/open-project");
|
||||
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function scrollToHomeFooter(page: Page) {
|
||||
await page.mouse.move(160, 350);
|
||||
await page.mouse.wheel(0, 1500);
|
||||
}
|
||||
|
||||
async function expectHomeActionsReachable(page: Page) {
|
||||
const providers = page.getByTestId("open-project-setup-providers");
|
||||
const community = page.getByRole("button", { name: "Community", exact: true });
|
||||
await expect(providers).toBeInViewport({ ratio: 1 });
|
||||
await expect(community).toBeInViewport({ ratio: 1 });
|
||||
const providerBounds = await providers.boundingBox();
|
||||
const communityBounds = await community.boundingBox();
|
||||
expect(providerBounds).not.toBeNull();
|
||||
expect(communityBounds).not.toBeNull();
|
||||
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
|
||||
}
|
||||
|
||||
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openProjectHome(page);
|
||||
await scrollToHomeFooter(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
|
||||
});
|
||||
|
||||
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openProjectHome(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
|
||||
"-webkit-app-region",
|
||||
"none",
|
||||
);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send agent prompt",
|
||||
label: "Send message",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -188,3 +188,142 @@ async function measureInlineCode(message: Locator) {
|
||||
return { codes, right: paragraph.getBoundingClientRect().right };
|
||||
});
|
||||
}
|
||||
|
||||
test("inline-code paths preserve stable recognition and file navigation", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
|
||||
const source =
|
||||
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
|
||||
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-path-regression-",
|
||||
title: "Inline path regression",
|
||||
initialPrompt: "Show file paths",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
});
|
||||
try {
|
||||
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
|
||||
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
|
||||
await writeFile(
|
||||
`${session.cwd}/src/index.ts`,
|
||||
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
|
||||
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
|
||||
for (const path of paths) {
|
||||
await openInlineRegressionFile(page, path);
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
|
||||
`Opened ${path}`,
|
||||
);
|
||||
}
|
||||
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
|
||||
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-content")
|
||||
.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(
|
||||
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-selectionBackground"),
|
||||
).not.toHaveCount(0);
|
||||
await testInfo.attach("inline-path-line-selection", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openInlineRegressionFile(page: Page, path: string) {
|
||||
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("assistant-message")
|
||||
.getByRole("link", { name: path, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Web links are styled inline by the library, so hover state has to restyle the anchor.
|
||||
test("markdown links underline on hover", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-link-hover-",
|
||||
title: "Markdown link hover",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
|
||||
},
|
||||
initialPrompt: "Show links",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
for (const name of ["the docs", "README"]) {
|
||||
const link = message.getByRole("link", { name, exact: true });
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
await link.hover();
|
||||
await expect(link).toHaveCSS("text-decoration-line", "underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// The web code block replaces the library's own block, which must keep its spacing.
|
||||
test("blocks around a web code block keep their spacing", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-block-spacing-",
|
||||
title: "Markdown block spacing",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
|
||||
},
|
||||
initialPrompt: "Show blocks",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("table")).toBeVisible();
|
||||
const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
|
||||
for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureBlockGaps(root: Locator) {
|
||||
return root.evaluate((element) => {
|
||||
const blocks = Array.from(element.children).map((block) => {
|
||||
// Wrappers with display: contents have no box; measure their first rendered child.
|
||||
let box: Element = block;
|
||||
while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
|
||||
box = box.firstElementChild;
|
||||
}
|
||||
const rect = box.getBoundingClientRect();
|
||||
return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
return blocks.slice(1).map((block, index) => ({
|
||||
between: `${blocks[index].text} -> ${block.text}`,
|
||||
gap: Math.round(block.top - blocks[index].bottom),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -316,6 +317,47 @@ export default function contribute(plugin) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
|
||||
const gatedBuildCommand = [
|
||||
'const fs = require("node:fs");',
|
||||
'fs.writeFileSync(process.argv[1], "started");',
|
||||
"const wait = () => {",
|
||||
" if (fs.existsSync(process.argv[2])) return;",
|
||||
" setTimeout(wait, 50);",
|
||||
"};",
|
||||
"wait();",
|
||||
].join("\n");
|
||||
|
||||
async function createGatedBuildRepository(
|
||||
root: string,
|
||||
gate: { started: string; release: string },
|
||||
): Promise<string> {
|
||||
const repository = path.join(root, "gated-repository");
|
||||
await mkdir(repository);
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "gated-build-plugin",
|
||||
description: "Holds the plugin lifecycle queue while its build waits",
|
||||
requirements: pluginRequirements,
|
||||
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
@@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription(
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
|
||||
function observePluginReload(page: Page) {
|
||||
const requests: number[] = [];
|
||||
const responses: number[] = [];
|
||||
const isSessionMessage = (payload: unknown, type: string): boolean => {
|
||||
if (typeof payload !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
return message?.type === type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
page.on("websocket", (socket) => {
|
||||
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
|
||||
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
|
||||
requestedAt: () => requests[0]!,
|
||||
requestCount: () => requests.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
|
||||
async function holdPluginLifecycleQueue(
|
||||
client: PluginEnvironment["client"],
|
||||
root: string,
|
||||
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
|
||||
const gate = {
|
||||
started: path.join(root, "build-started"),
|
||||
release: path.join(root, "build-release"),
|
||||
};
|
||||
const repository = await createGatedBuildRepository(root, gate);
|
||||
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
|
||||
// The marker wait below can outlive an early failure: a refused preparation, a dropped
|
||||
// connection, or a build command that exits before writing its marker. Handle that rejection now
|
||||
// so it never surfaces as an unhandled one, and drain the same promise in finish().
|
||||
const installDrained = install.catch(() => undefined);
|
||||
// Every path releases the gate and drains the install: a build still polling for its gate holds
|
||||
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
|
||||
const finish = async () => {
|
||||
await writeFile(gate.release, "go").catch(() => undefined);
|
||||
await installDrained;
|
||||
};
|
||||
try {
|
||||
// Whichever comes first decides the scenario: the build reports that it started, or the setup
|
||||
// fails with the daemon's own error instead of a bare marker timeout.
|
||||
await Promise.race([
|
||||
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
|
||||
install,
|
||||
]);
|
||||
} catch (error) {
|
||||
// Setup that never reached the build must not hand the caller a queue it cannot settle.
|
||||
await finish();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
release: () => writeFile(gate.release, "go"),
|
||||
settle: finish,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
|
||||
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
|
||||
const remaining = 66_000 - (Date.now() - requestedAt);
|
||||
if (remaining > 0) await page.waitForTimeout(remaining);
|
||||
}
|
||||
|
||||
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { client, directory } = pluginEnvironment;
|
||||
const pluginDirectory = await createDirectoryPlugin(
|
||||
directory,
|
||||
"e2e-plugin",
|
||||
"Exercises the complete local plugin lifecycle",
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -223,7 +223,10 @@ test.describe("Projects settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
test("edits worktree setup, navigates within the host, and removes a script", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
@@ -236,15 +239,22 @@ test.describe("Projects settings", () => {
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
await test.step("navigation stays inside the selected host", async () => {
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
await test.step("removing a script leaves the committed setup unchanged", async () => {
|
||||
await expectScriptRowCount(page, 1);
|
||||
await removeProjectScript(page, "dev");
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
@@ -259,18 +269,7 @@ test.describe("Projects settings", () => {
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
|
||||
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
|
||||
test("reopening the edit sheet seeds from the saved project", async ({
|
||||
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
@@ -279,26 +278,26 @@ test.describe("Projects settings", () => {
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
|
||||
await openProjectEditSheet(page);
|
||||
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
|
||||
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
|
||||
await openProjectEditSheet(page);
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await test.step("an icon can be saved without another name change", async () => {
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
});
|
||||
|
||||
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
|
||||
@@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("project detail does not render a second host selector", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectHostContextHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { killProcessTree } from "../support/helpers/spawn-node";
|
||||
import {
|
||||
buildAgentRoute,
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
continueToNextQuestion,
|
||||
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const questionDaemonMessage = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("attempt"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resolved"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
pendingRequestIds: z.array(z.string()),
|
||||
}),
|
||||
z.object({ type: z.literal("error"), error: z.string() }),
|
||||
]);
|
||||
|
||||
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
|
||||
|
||||
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
|
||||
|
||||
interface QuestionRetryControl {
|
||||
attempts: QuestionResponseAttempt[];
|
||||
resolutions: QuestionResolution[];
|
||||
releaseFirstResponse(): void;
|
||||
}
|
||||
|
||||
const retryTest = test.extend<{
|
||||
questionClient: QuestionRetryControl;
|
||||
}>({
|
||||
questionClient: async ({ page }, provide, testInfo) => {
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
|
||||
cwd: serverDir,
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
const output: string[] = [];
|
||||
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const attempts: QuestionResponseAttempt[] = [];
|
||||
const resolutions: QuestionResolution[] = [];
|
||||
child.on("message", (message: unknown) => {
|
||||
const parsed = questionDaemonMessage.parse(message);
|
||||
switch (parsed.type) {
|
||||
case "ready":
|
||||
ready.resolve(parsed);
|
||||
break;
|
||||
case "attempt":
|
||||
attempts.push(parsed);
|
||||
break;
|
||||
case "resolved":
|
||||
resolutions.push(parsed);
|
||||
break;
|
||||
case "error":
|
||||
ready.reject(new Error(parsed.error));
|
||||
break;
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Question daemon startup timed out")),
|
||||
20_000,
|
||||
);
|
||||
try {
|
||||
const daemon = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
port: daemon.port,
|
||||
repoPrefix: "question-retry-",
|
||||
title: "Question response retry",
|
||||
initialPrompt: "Emit synthetic questions.",
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
const serverId = daemon.serverId;
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "Question retry host",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.evaluate((extraHost) => {
|
||||
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
|
||||
}, host);
|
||||
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(child);
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("question-daemon", {
|
||||
body: output.join(""),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
retryTest(
|
||||
"failed question submission preserves answers and allows retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Preserve my answers after a failed submission.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(card.getByRole("progressbar")).toBeVisible();
|
||||
await expectQuestionPrimaryActionDisabled(page, "Submit");
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
|
||||
await expect(card.getByRole("tab").first()).toBeDisabled();
|
||||
await expect(card.getByRole("textbox")).not.toBeEditable();
|
||||
questionClient.releaseFirstResponse();
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "Behind feature flag");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue(
|
||||
"Preserve my answers after a failed submission.",
|
||||
);
|
||||
await submitQuestionAnswers(page);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
surface: "App",
|
||||
rollout: "Behind feature flag",
|
||||
success: "Preserve my answers after a failed submission.",
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
retryTest(
|
||||
"failed question dismissal allows an immediate retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
questionClient.releaseFirstResponse();
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Start with the internal team.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
|
||||
await expect(card.getByRole("alert")).toBeVisible();
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toEqual({
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -588,6 +589,69 @@ async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const compact of [false, true]) {
|
||||
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
|
||||
});
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active quota" },
|
||||
});
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
submitAgentChatAndExpectCwd,
|
||||
} from "../support/helpers/workspace-lifecycle";
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
@@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => {
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await test.step("submitting a draft creates an agent in the project root", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
await submitAgentChatAndExpectCwd(page, workspace);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
|
||||
@@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the New tab button is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -132,13 +125,6 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,3 +135,14 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
if (compact) {
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(text);
|
||||
return;
|
||||
}
|
||||
await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text);
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { connectSeedClient } from "./seed-client";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
@@ -30,3 +31,35 @@ export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function submitAgentChatAndExpectCwd(
|
||||
page: Page,
|
||||
workspace: { workspaceId: string; repoPath: string },
|
||||
): Promise<void> {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const message = "cwd draft create";
|
||||
await composerInput(page).fill(message);
|
||||
await composerInput(page).press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd }));
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -118,8 +118,8 @@ export function ChatFind({
|
||||
}, [active, isInteractive, model]);
|
||||
const close = useCallback(() => {
|
||||
model.close();
|
||||
root.current?.focus({ preventScroll: true });
|
||||
}, [model]);
|
||||
viewportRef.current?.focus?.();
|
||||
}, [model, viewportRef]);
|
||||
let status = "";
|
||||
if (state.phase === "searching") status = t("paneFind.searching");
|
||||
else if (state.phase === "loading") status = t("paneFind.loading");
|
||||
|
||||
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
|
||||
]);
|
||||
expect(findRenderedMatches(row, ".another")).toEqual([]);
|
||||
});
|
||||
|
||||
function pluginRow(html: string) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.pluginRow = "true";
|
||||
row.dataset.messageId = "message-1";
|
||||
row.innerHTML = html;
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
|
||||
const row = pluginRow(
|
||||
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
|
||||
"needle",
|
||||
"needle",
|
||||
]);
|
||||
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
|
||||
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves rows without the plugin marker limited to marked message text", () => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = "<div>needle</div>";
|
||||
document.body.append(row);
|
||||
expect(findRenderedMatches(row, "needle")).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts only drawn text in a plugin row, including an opened popover", () => {
|
||||
const row = pluginRow(
|
||||
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
|
||||
row.querySelector<HTMLElement>("[popover]")!.showPopover();
|
||||
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps text around nested plugin blocks in separate occurrences", () => {
|
||||
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
|
||||
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
|
||||
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
@@ -21,28 +21,55 @@ export function findMessageMatches(
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
function renderedTextGroups(row: HTMLElement): Text[][] {
|
||||
const groups: Text[][] = [];
|
||||
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
|
||||
// whole row is searched and each div (a React Native View or Text) bounds a match.
|
||||
const pluginRow = row.dataset.pluginRow === "true";
|
||||
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
|
||||
const contents = pluginRow
|
||||
? [row]
|
||||
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
|
||||
for (const content of contents) {
|
||||
let group: { block: Element; texts: Text[] } | undefined;
|
||||
const walker = document.createTreeWalker(
|
||||
content,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node instanceof Element) {
|
||||
// Even an empty nested block separates the text before and after it.
|
||||
if (node.matches(blocks)) group = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
if (
|
||||
pluginRow &&
|
||||
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
)
|
||||
continue;
|
||||
const block = node.parentElement?.closest(blocks) ?? content;
|
||||
if (group?.block !== block) {
|
||||
group = { block, texts: [] };
|
||||
groups.push(group.texts);
|
||||
}
|
||||
group.texts.push(node);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
const groups = new Map<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
const block = node.parentElement?.closest(BLOCKS) ?? content;
|
||||
const texts = groups.get(block) ?? [];
|
||||
texts.push(node);
|
||||
groups.set(block, texts);
|
||||
}
|
||||
}
|
||||
const pattern = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
const ranges: Range[] = [];
|
||||
for (const texts of groups.values()) {
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
|
||||
describe("native scroll-to-bottom animation", () => {
|
||||
it("jumps across unmounted history instead of animating through blank rows", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps near-bottom movement eligible for animation", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds travel by the bottom-side overscan, not the whole window", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not animate before the viewport has been measured", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function shouldAnimateNativeScrollToBottom(input: {
|
||||
offsetY: number;
|
||||
viewportHeight: number;
|
||||
windowSize: number;
|
||||
}): boolean {
|
||||
// VirtualizedList splits the extra window around the viewport. An animated
|
||||
// jump farther than the bottom-side overscan crosses unmounted history.
|
||||
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
|
||||
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardStreamInset } from "@/keyboard/shift";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// A history row can contain an entire long reply. Keep synchronous native
|
||||
// Markdown measurement bounded while FlatList fills the surrounding window.
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
|
||||
scrollToBottom: () => {
|
||||
setFollowOutput(true);
|
||||
cancelPendingStickToBottom();
|
||||
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
|
||||
}
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
|
||||
@@ -87,13 +87,10 @@ import {
|
||||
} from "./bottom-anchor-controller";
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
@@ -387,7 +384,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
const { revealDirectory } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceRoot,
|
||||
@@ -426,63 +423,53 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
const openInlineFile = useStableEvent(
|
||||
(location: WorkspaceFileLocation, disposition: OpenFileDisposition) => {
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({ location, disposition });
|
||||
} else if (context.workspaceId) {
|
||||
navigateToWorkspace({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
const openInlineDirectory = useStableEvent((directoryPath: string) => {
|
||||
void revealDirectory(directoryPath);
|
||||
openExplorerSidebarView({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
return () => inlinePathOpener.cancel();
|
||||
}, [inlinePathOpener, isActive, agentId]);
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({
|
||||
location,
|
||||
disposition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.workspaceId) {
|
||||
navigateToWorkspace({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void requestDirectoryListing(normalized.directory, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
openExplorerSidebarView({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
if (isActive) void inlinePathOpener.open(target, disposition);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -519,7 +506,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window and turn-lifecycle renders from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
|
||||
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||
@@ -785,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}
|
||||
@@ -809,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
|
||||
@@ -1531,11 +1518,17 @@ function PermissionRequestCard({
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
const responseFailed =
|
||||
permissionMutation.isError &&
|
||||
permissionMutation.variables?.agentId === permission.agentId &&
|
||||
permissionMutation.variables?.requestId === permission.request.id;
|
||||
const responseError = responseFailed ? t("message.question.responseFailed") : null;
|
||||
return (
|
||||
<QuestionFormCard
|
||||
permission={permission}
|
||||
onRespond={handleResponse}
|
||||
isResponding={isResponding}
|
||||
responseError={responseError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,3 +10,5 @@ export {
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
|
||||
|
||||
export { createInlinePathOpener } from "./path-open";
|
||||
|
||||
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
|
||||
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
/** Authored links identify a file; only inferred text needs filename heuristics. */
|
||||
explicitLink?: boolean;
|
||||
/**
|
||||
* True only when `value` is a markdown href — the renderer percent-encodes
|
||||
* non-ASCII and space characters, so the path must be decoded. Raw path
|
||||
@@ -234,7 +236,7 @@ export function classifyAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
@@ -278,6 +280,7 @@ export function parseAssistantFileLink(
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
explicitLink: options.explicitLink,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
@@ -437,7 +440,7 @@ function parseLocalPathParts(
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -458,7 +461,7 @@ function parseLocalPathParts(
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { resolveInlinePathOpen } from "./path-open";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("distinguishes dotted and extensionless folders from files using the filesystem", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-"));
|
||||
roots.push(workspaceRoot);
|
||||
await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true });
|
||||
await mkdir(path.join(workspaceRoot, "docs/reports.v1"));
|
||||
await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n");
|
||||
await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n");
|
||||
const client = {
|
||||
listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)),
|
||||
};
|
||||
for (const file of ["docs/guides", "docs/reports.v1"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }),
|
||||
).resolves.toEqual({ kind: "directory", directoryPath: file });
|
||||
}
|
||||
for (const file of ["Makefile", "README.md", "missing.md"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit directory destinations and file line references", async () => {
|
||||
const workspaceRoot = "/repo";
|
||||
await expect(
|
||||
resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }),
|
||||
).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" });
|
||||
await expect(
|
||||
resolveInlinePathOpen({
|
||||
file: "docs/guides",
|
||||
directory: "docs",
|
||||
lineStart: 12,
|
||||
workspaceRoot,
|
||||
client: null,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
});
|
||||
|
||||
it("keeps unavailable probes and external paths on the existing file-open path", async () => {
|
||||
for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores an older probe after a newer file link opens", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
let rejectProbe!: (error: Error) => void;
|
||||
const pendingProbe = new Promise<never>((_, reject) => {
|
||||
rejectProbe = reject;
|
||||
});
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
rejectProbe(new Error("Not a directory"));
|
||||
await first;
|
||||
expect(opened).toEqual(["second.md"]);
|
||||
});
|
||||
|
||||
it("ignores a pending probe when its view is left", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
let resolveProbe!: (value: unknown) => void;
|
||||
const pendingProbe = new Promise((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
resolveProbe({});
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse";
|
||||
import {
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
directory: string;
|
||||
lineStart?: number;
|
||||
workspaceRoot: string;
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
isAbsolutePath(input.file) ||
|
||||
!input.workspaceRoot.trim() ||
|
||||
!input.client
|
||||
) {
|
||||
return { kind: "file" };
|
||||
}
|
||||
try {
|
||||
await input.client.listDirectory(input.workspaceRoot, input.file);
|
||||
} catch {
|
||||
return { kind: "file" };
|
||||
}
|
||||
return { kind: "directory", directoryPath: input.file };
|
||||
}
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
async open(target: InlinePathTarget, disposition: OpenFileDisposition) {
|
||||
const ownGeneration = ++generation;
|
||||
const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot);
|
||||
if (!normalized) return;
|
||||
const destination = await resolveInlinePathOpen({
|
||||
...normalized,
|
||||
lineStart: target.lineStart,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
client: input.client,
|
||||
});
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
generation += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,63 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
|
||||
"resolves an explicit extensionless folder href: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => {
|
||||
expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
|
||||
"keeps auto-recognized extensionless text ignored: %j",
|
||||
(origin) => {
|
||||
expect(
|
||||
classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT),
|
||||
).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["docs/guides?view=all"])(
|
||||
"does not turn URL-like hrefs into workspace paths: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
".dev/tablet-review/tablet-agent-draft.png",
|
||||
"images/capture.jpg",
|
||||
"downloads/archive.custom",
|
||||
"assets/LICENSE",
|
||||
"example.com/guides",
|
||||
"capture.png",
|
||||
"missing.png",
|
||||
])("opens explicit file link %s without filename guessing", (href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
|
||||
@@ -117,6 +117,7 @@ export function classifyForResolution(
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
|
||||
onCaptureData(pcm: Uint8Array): void;
|
||||
onVolumeLevel(level: number): void;
|
||||
onInterruption?(): void;
|
||||
onCaptureAvailabilityChange?(available: boolean): void;
|
||||
onError?(error: Error): void;
|
||||
}
|
||||
|
||||
@@ -25,3 +26,8 @@ export interface AudioEngine {
|
||||
clearQueue(): void;
|
||||
isPlaying(): boolean;
|
||||
}
|
||||
|
||||
export interface AudioEngineOptions {
|
||||
traceLabel?: string;
|
||||
captureMode?: "conversation" | "dictation";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioEngineOptions,
|
||||
AudioPlaybackSource,
|
||||
} from "./audio-engine-types";
|
||||
|
||||
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: { traceLabel?: string },
|
||||
options?: AudioEngineOptions,
|
||||
): AudioEngine {
|
||||
const native = require("@getpaseo/expo-two-way-audio");
|
||||
|
||||
@@ -47,6 +52,12 @@ export function createAudioEngine(
|
||||
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onAudioInterruption",
|
||||
(event: { data: string }) => {
|
||||
if (event.data === "capture_paused" || event.data === "capture_resumed") {
|
||||
if (refs.captureActive) {
|
||||
callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.data !== "blocked") {
|
||||
return;
|
||||
}
|
||||
@@ -180,7 +191,10 @@ export function createAudioEngine(
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
const isRecording = native.toggleRecording(true);
|
||||
const isRecording =
|
||||
options?.captureMode === "dictation"
|
||||
? native.startDictation()
|
||||
: native.toggleRecording(true);
|
||||
if (!isRecording) {
|
||||
throw new Error(
|
||||
"Microphone capture could not start because Android audio focus is unavailable.",
|
||||
@@ -201,6 +215,7 @@ export function createAudioEngine(
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
callbacks.onCaptureAvailabilityChange?.(true);
|
||||
releaseSessionIfIdle();
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioEngineOptions,
|
||||
AudioPlaybackSource,
|
||||
} from "./audio-engine-types";
|
||||
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
|
||||
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: { traceLabel?: string },
|
||||
_options?: AudioEngineOptions,
|
||||
): AudioEngine {
|
||||
const refs: {
|
||||
playbackContext: AudioContext | null;
|
||||
|
||||
@@ -87,7 +87,9 @@ export function DictationControls({
|
||||
<View style={styles.meterWrapper}>
|
||||
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
|
||||
</View>
|
||||
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
|
||||
<Text style={timerTextStyle}>
|
||||
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
|
||||
</Text>
|
||||
<View style={styles.actionGroup}>
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
@@ -210,6 +212,11 @@ export function DictationOverlay({
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
{status === "paused" ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{t("message.dictation.paused")}
|
||||
</Text>
|
||||
) : null}
|
||||
{isFailed ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{errorText
|
||||
|
||||
@@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import {
|
||||
IconButtonChromeProvider,
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
@@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import {
|
||||
@@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
|
||||
@@ -545,6 +554,11 @@ function MobileSidebar({
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const closeButton = useMemo(
|
||||
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
|
||||
[labels.closeSidebar, closeSidebar],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
@@ -562,30 +576,11 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
pointerEvents="box-none"
|
||||
style={styles.mobileCloseButtonRow}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -626,6 +621,34 @@ function MobileSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const NO_SHORTCUT: ShortcutKey[] = [];
|
||||
|
||||
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
|
||||
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
return (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<HeaderToggleButton
|
||||
onPress={onPress}
|
||||
tooltipLabel={label}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="bottom"
|
||||
style={styles.mobileCloseButton}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<ThemedX
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
workspaceGroups,
|
||||
@@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileCloseButtonRow: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
// This optical inset puts their painted right edges on the same sidebar rail.
|
||||
marginRight: theme.spacing[2] + 1.5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
desktopSidebarBorder: {
|
||||
borderRightWidth: 1,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -31,6 +36,56 @@ describe("shared Markdown source adaptation", () => {
|
||||
"> - \n\n[picture]: ../a.png",
|
||||
);
|
||||
});
|
||||
it("gives an image alone on its line its own paragraph", () => {
|
||||
expect(
|
||||
prepareMarkdownSource("Before the shot:\n\nAfter it.").render(),
|
||||
).toBe("Before the shot:\n\n\n\nAfter it.");
|
||||
expect(prepareMarkdownSource("\n").render()).toBe(
|
||||
"\n\n",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource(
|
||||
"Line one\\\n[](https://x)\nLine three",
|
||||
).render(),
|
||||
).toBe("Line one\n\n[](https://x)\n\nLine three");
|
||||
});
|
||||
it("keeps images that share a line with text inline", () => {
|
||||
const source = "Icon  in text\n caption\nNext line";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
});
|
||||
it("splits own-line images inside quotes and list items without ending the container", () => {
|
||||
expect(prepareMarkdownSource("> Quote text\n> \n> More").render()).toBe(
|
||||
"> Quote text\n>\n> \n>\n> More",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource("- Item text\n \n More\n- Next").render(),
|
||||
).toBe("- Item text\n\n \n\n More\n- Next");
|
||||
expect(prepareMarkdownSource("- \n Caption line").render()).toBe(
|
||||
"- \n\n Caption line",
|
||||
);
|
||||
});
|
||||
it("renders own-line images as blocks through the library parser", async () => {
|
||||
const source =
|
||||
"Before:\n\nIcon  in text\n> Quoted\n> ";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
markInlineImages(tree);
|
||||
const images: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Image") images.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(
|
||||
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
|
||||
).toEqual([
|
||||
["https://x/a.png", false],
|
||||
["https://x/i.png", true],
|
||||
["https://x/q.png", false],
|
||||
]);
|
||||
});
|
||||
it("escapes spoiler delimiters only in prose", () => {
|
||||
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
|
||||
"Show \\|\\|words\\|\\| and `||code||`.",
|
||||
@@ -59,7 +114,7 @@ it.each([
|
||||
"src/file.ts:10-20",
|
||||
"src/file.ts(10,2)",
|
||||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
|
||||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||||
href: path,
|
||||
@@ -68,6 +123,106 @@ it.each([
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stable inline-code file classification through the library parser", async () => {
|
||||
const paths = [
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"src/index.ts:20-10",
|
||||
"src/index.ts#L0",
|
||||
"src/index.ts?raw",
|
||||
"before/after",
|
||||
"word.word",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"src/file.ts lines 10-20",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
];
|
||||
const source = paths.map((path) => `\`${path}\``).join(" and ");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(links).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
]);
|
||||
expect(
|
||||
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
|
||||
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
|
||||
});
|
||||
|
||||
it("lets the engine own code boundaries while classifying candidate values", async () => {
|
||||
const source = [
|
||||
"```ts\n`src/fenced.ts`\n```",
|
||||
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
|
||||
"``src/a`b.ts`` and `src/after.ts`",
|
||||
"\\`src/escaped.ts` and `src/final.ts`",
|
||||
"`\nsrc/newline.ts\n`",
|
||||
"[`src/explicit.ts`](https://example.com)",
|
||||
].join("\n\n");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const recognized: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
|
||||
recognized.push(String(node.attributes.url));
|
||||
}
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(recognized).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
" src/spaced.ts ",
|
||||
"src/a`b.ts",
|
||||
"src/after.ts",
|
||||
"src/escaped.ts",
|
||||
"src/final.ts",
|
||||
"src/newline.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||||
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
|
||||
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
|
||||
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
|
||||
expect(withoutWorkspace.test("README.md")).toBe(false);
|
||||
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const native = toNativeLinkRecognition(prepared.linkRecognition);
|
||||
const pattern = new RegExp(native.inlineCode.pattern);
|
||||
expect(pattern.test("src/app/[id].tsx")).toBe(true);
|
||||
expect(pattern.test("src/a+b.ts")).toBe(true);
|
||||
expect(pattern.test("src/app/i.tsx")).toBe(false);
|
||||
expect(pattern.test("src/ab.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||||
@@ -436,7 +591,7 @@ it.each([
|
||||
"×/÷",
|
||||
"2024/10/16",
|
||||
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
|
||||
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
@@ -461,12 +616,11 @@ it.each([
|
||||
"123.ts",
|
||||
"FILE.TS",
|
||||
"https://example.com/path",
|
||||
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
|
||||
])("recognizes complete paths and URLs in prose: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
|
||||
expect(
|
||||
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
|
||||
).toEqual([token]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -477,10 +631,13 @@ it.each([
|
||||
"packages/app",
|
||||
"123/file",
|
||||
"src/123",
|
||||
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
|
||||
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(
|
||||
`Compare ${token}, then \`${token}\`.`,
|
||||
"/repo",
|
||||
);
|
||||
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from "react-native";
|
||||
import { forwardRef, useMemo, type ComponentProps } from "react";
|
||||
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
|
||||
import type {
|
||||
CodeBlockRendererProps,
|
||||
LinkRendererProps,
|
||||
@@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
|
||||
}
|
||||
|
||||
// The library styles links inline, so a stylesheet :hover rule cannot underline them.
|
||||
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
|
||||
|
||||
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
|
||||
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleMouseEnter = useCallback(() => setHovered(true), []);
|
||||
const handleMouseLeave = useCallback(() => setHovered(false), []);
|
||||
const anchorStyle = useMemo(
|
||||
() => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }),
|
||||
[anchorProps.style],
|
||||
() => ({
|
||||
...anchorProps.style,
|
||||
overflowWrap: "anywhere" as const,
|
||||
...(hovered ? LINK_HOVER_STYLE : null),
|
||||
}),
|
||||
[anchorProps.style, hovered],
|
||||
);
|
||||
const anchor = (
|
||||
<a {...anchorProps} style={anchorStyle}>
|
||||
<a
|
||||
{...anchorProps}
|
||||
style={anchorStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
@@ -5,7 +6,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import type { Link, Nodes, Root } from "mdast";
|
||||
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
|
||||
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
|
||||
// Bare prose paths need a known extension, a line suffix, or at least two separators.
|
||||
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
|
||||
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
|
||||
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
|
||||
}
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
|
||||
|
||||
function linkMetadata() {
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
// first, then pass literal matches rather than maintaining a second path grammar.
|
||||
const alternatives = [...inlineCodePaths].map((path) =>
|
||||
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
return {
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
|
||||
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
|
||||
),
|
||||
},
|
||||
linkSource: markdownLinkSource,
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
// Markdown context (escapes, fences, authored links); extra candidates are harmless
|
||||
// because recognition only applies to complete Code nodes. This avoids an AST
|
||||
// parse on every streaming reveal, including updates inside long code fences.
|
||||
for (const match of source.matchAll(/`+/g)) {
|
||||
const length = match[0].length;
|
||||
const previousEnd = previousEnds.get(length);
|
||||
previousEnds.set(length, match.index + length);
|
||||
if (previousEnd === undefined) continue;
|
||||
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
|
||||
// CommonMark code spans replace line endings and strip one surrounding space.
|
||||
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function markdownLinkSource(
|
||||
url: string,
|
||||
sourceType?: "markdown" | "text" | "inline-code",
|
||||
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes(")
|
||||
) {
|
||||
return { images, ...linkMetadata(), render: () => source };
|
||||
return { images, ...metadata, render: () => source };
|
||||
}
|
||||
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
@@ -130,10 +157,50 @@ export function prepareMarkdownSource(
|
||||
end,
|
||||
});
|
||||
}
|
||||
// Line boundaries already turned into paragraph breaks, by offset of their newline.
|
||||
const splitBoundaries = new Set<number>();
|
||||
function isImageLike(node: PhrasingContent): boolean {
|
||||
if (node.type === "image" || node.type === "imageReference") return true;
|
||||
return (
|
||||
(node.type === "link" || node.type === "linkReference") &&
|
||||
node.children.length === 1 &&
|
||||
isImageLike(node.children[0])
|
||||
);
|
||||
}
|
||||
// An image alone on its line becomes its own paragraph, so every platform renders
|
||||
// it as a block. The library only treats sole-child paragraph images as blocks;
|
||||
// with hardSoftBreaks every newline is a break node, which made these inline.
|
||||
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
|
||||
for (const child of paragraph.children) {
|
||||
const childStart = child.position?.start.offset;
|
||||
const childEnd = child.position?.end.offset;
|
||||
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
|
||||
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
|
||||
const newline = source.indexOf("\n", childEnd);
|
||||
const lineEnd = newline === -1 ? source.length : newline;
|
||||
const prefix = source.slice(lineStart, childStart);
|
||||
const suffix = source.slice(childEnd, lineEnd);
|
||||
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
|
||||
const aloneAfter = /^\s*\\?$/.test(suffix);
|
||||
if (!aloneBefore || !aloneAfter) continue;
|
||||
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
|
||||
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
|
||||
const split = (boundary: number) => {
|
||||
if (splitBoundaries.has(boundary)) return;
|
||||
splitBoundaries.add(boundary);
|
||||
// A trailing backslash was a hard break; at a paragraph end it would render literally.
|
||||
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
|
||||
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
|
||||
};
|
||||
if (lineStart > start) split(lineStart - 1);
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
@@ -166,7 +233,7 @@ export function prepareMarkdownSource(
|
||||
visit(getTree());
|
||||
return {
|
||||
images,
|
||||
...linkMetadata(),
|
||||
...metadata,
|
||||
render(
|
||||
urls: ReadonlyMap<string, string> = new Map(),
|
||||
unavailableImages: ReadonlySet<string> = new Set(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
serverId?: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -2228,6 +2230,7 @@ interface ToolCallProps {
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
serverId,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
@@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail: effectiveDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
targetAgentTitle,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -2393,6 +2398,8 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.toolName !== next.toolName) return false;
|
||||
if (previous.serverId !== next.serverId) return false;
|
||||
if (previous.agentMessage !== next.agentMessage) return false;
|
||||
if (previous.args !== next.args) return false;
|
||||
if (previous.result !== next.result) return false;
|
||||
if (previous.error !== next.error) return false;
|
||||
|
||||
@@ -58,6 +58,7 @@ function mountCard(question: Record<string, unknown>) {
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
responseError={null}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
@@ -27,6 +28,7 @@ interface QuestionFormCardProps {
|
||||
permission: PendingPermission;
|
||||
onRespond: (response: AgentPermissionResponse) => void;
|
||||
isResponding: boolean;
|
||||
responseError: string | null;
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
@@ -339,7 +341,12 @@ function QuestionOtherInput({
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
export function QuestionFormCard({
|
||||
permission,
|
||||
onRespond,
|
||||
isResponding,
|
||||
responseError,
|
||||
}: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -438,7 +445,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
]);
|
||||
|
||||
const handleDeny = useCallback(() => {
|
||||
if (!questions) return;
|
||||
if (!questions || isResponding) return;
|
||||
setRespondingAction("dismiss");
|
||||
if (shouldSubmitEmptyOnDismiss(questions)) {
|
||||
onRespond({
|
||||
@@ -454,7 +461,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||
}, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]);
|
||||
|
||||
const handleSelectQuestion = useCallback((index: number) => {
|
||||
setActiveQuestionIndex(index);
|
||||
@@ -487,6 +494,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
|
||||
);
|
||||
|
||||
const pendingAction = isResponding ? respondingAction : null;
|
||||
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
|
||||
const primaryActionLabel = isLastQuestion
|
||||
? t("message.question.submit")
|
||||
@@ -550,7 +558,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
|
||||
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||
const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
@@ -604,6 +612,8 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{responseError ? <Alert variant="error" description={responseError} size="sm" /> : null}
|
||||
|
||||
<View style={actionsContainerStyle}>
|
||||
<Pressable
|
||||
style={dismissButtonStyle}
|
||||
@@ -613,7 +623,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
accessibilityLabel={dismissLabel}
|
||||
testID="question-form-dismiss"
|
||||
>
|
||||
{respondingAction === "dismiss" ? (
|
||||
{pendingAction === "dismiss" ? (
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
@@ -631,7 +641,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
accessibilityLabel={primaryActionLabel}
|
||||
testID="question-form-primary-action"
|
||||
>
|
||||
{respondingAction === "submit" ? (
|
||||
{pendingAction === "submit" ? (
|
||||
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -1,8 +1,9 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
|
||||
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
@@ -30,37 +31,49 @@ interface SidebarNavRowProps {
|
||||
interface SidebarNavRowsProps extends SidebarNavRowProps {
|
||||
/** Style for the group wrapper, which the sidebar owns. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Sits to the right of the first row, which shrinks to make room. */
|
||||
firstRowTrailing?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level sidebar navigation, ordered and filtered by the user's
|
||||
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
|
||||
* wrapper — when every item is hidden.
|
||||
* wrapper — when every item is hidden and there is no `firstRowTrailing`.
|
||||
*/
|
||||
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
|
||||
export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) {
|
||||
const { items } = useSidebarNavItems("header");
|
||||
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
|
||||
const groupRef = useRef<View | null>(null);
|
||||
|
||||
if (visibleItems.length === 0) return null;
|
||||
if (visibleItems.length === 0 && !firstRowTrailing) return null;
|
||||
|
||||
const [firstRow, ...restRows] = visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={groupRef} collapsable={false} style={style}>
|
||||
{visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
})}
|
||||
{firstRowTrailing ? (
|
||||
<View style={styles.firstRow}>
|
||||
<View style={styles.firstRowItem}>{firstRow}</View>
|
||||
{firstRowTrailing}
|
||||
</View>
|
||||
) : (
|
||||
firstRow
|
||||
)}
|
||||
{restRows}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps
|
||||
search: SidebarSearchRow,
|
||||
schedules: SidebarSchedulesRow,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
firstRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
firstRowItem: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
// Lifts the composer off the chat it sits under on phones.
|
||||
inputWrapperCompact: {
|
||||
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
// Dotted says "this surface is the same box, but there is nothing to type
|
||||
// into it" without swapping the border colour, which reads as an error state.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import type { SortOption } from "@/stores/panel-store/state";
|
||||
import { filterVisibleExplorerEntries } from "./visibility";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
|
||||
|
||||
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
|
||||
|
||||
@@ -201,3 +201,24 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/** Load and expand the path to a directory selected outside the tree. */
|
||||
export async function revealExplorerDirectory(input: {
|
||||
directoryPath: string;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
expandPaths: (paths: string[]) => void;
|
||||
showHiddenFiles: () => void;
|
||||
}): Promise<void> {
|
||||
if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles();
|
||||
const paths = ["."];
|
||||
if (input.directoryPath !== ".") {
|
||||
const segments = input.directoryPath.split("/").filter(Boolean);
|
||||
for (let length = 1; length <= segments.length; length += 1) {
|
||||
paths.push(segments.slice(0, length).join("/"));
|
||||
}
|
||||
}
|
||||
for (const path of paths) {
|
||||
if (!(await input.requestDirectoryListing(path))) return;
|
||||
}
|
||||
input.expandPaths(paths);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
const onErrorRef = useRef(config.onError);
|
||||
const onInterruptionRef = useRef(config.onInterruption);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
|
||||
|
||||
const getOrCreateEngine = useCallback(() => {
|
||||
@@ -21,20 +22,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
return engineRef.current;
|
||||
}
|
||||
|
||||
engineRef.current = createAudioEngine({
|
||||
onCaptureData: (pcm) => {
|
||||
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
|
||||
engineRef.current = createAudioEngine(
|
||||
{
|
||||
onCaptureData: (pcm) => {
|
||||
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
|
||||
},
|
||||
onVolumeLevel: (level) => {
|
||||
setVolume(level);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorRef.current?.(error);
|
||||
},
|
||||
onInterruption: () => {
|
||||
onInterruptionRef.current?.();
|
||||
},
|
||||
onCaptureAvailabilityChange: (available) => setIsPaused(!available),
|
||||
},
|
||||
onVolumeLevel: (level) => {
|
||||
setVolume(level);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorRef.current?.(error);
|
||||
},
|
||||
onInterruption: () => {
|
||||
onInterruptionRef.current?.();
|
||||
},
|
||||
});
|
||||
{ captureMode: "dictation" },
|
||||
);
|
||||
return engineRef.current;
|
||||
}, []);
|
||||
|
||||
@@ -67,5 +72,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
start,
|
||||
stop,
|
||||
volume,
|
||||
isPaused,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface DictationAudioSource {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
volume: number;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
@@ -442,6 +442,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
},
|
||||
stop,
|
||||
volume,
|
||||
isPaused: false,
|
||||
}),
|
||||
[start, stop, volume],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
|
||||
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
|
||||
|
||||
export interface UseDictationOptions {
|
||||
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
|
||||
|
||||
@@ -461,7 +461,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
volume: audio.volume,
|
||||
duration,
|
||||
error,
|
||||
status,
|
||||
status: status === "recording" && audio.isPaused ? "paused" : status,
|
||||
startDictation,
|
||||
cancelDictation,
|
||||
confirmDictation,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealExplorerDirectory } from "@/file-explorer/tree";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
@@ -172,6 +174,27 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
);
|
||||
|
||||
const revealDirectory = useCallback(
|
||||
async (directoryPath: string) => {
|
||||
if (!workspaceStateKey) return;
|
||||
await revealExplorerDirectory({
|
||||
directoryPath,
|
||||
requestDirectoryListing,
|
||||
showHiddenFiles: () => {
|
||||
const panel = usePanelStore.getState();
|
||||
if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles();
|
||||
},
|
||||
expandPaths: (paths) =>
|
||||
usePanelStore
|
||||
.getState()
|
||||
.setExpandedPathsForWorkspace(workspaceStateKey, (current) =>
|
||||
Array.from(new Set([...current, ...paths])),
|
||||
),
|
||||
});
|
||||
},
|
||||
[requestDirectoryListing, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
async (path: string) => {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -354,6 +377,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
|
||||
return {
|
||||
workspaceStateKey,
|
||||
revealDirectory,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
|
||||
@@ -360,6 +360,7 @@ export const ar: TranslationResources = {
|
||||
details: "تفاصيل",
|
||||
},
|
||||
dictation: {
|
||||
paused: "تمت مقاطعة الميكروفون. في انتظار الاستئناف…",
|
||||
start: "بدء الإملاء الصوتي",
|
||||
cancel: "إلغاء الإملاء",
|
||||
retry: "أعد محاولة الإملاء",
|
||||
@@ -369,6 +370,7 @@ export const ar: TranslationResources = {
|
||||
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
|
||||
submit: "يُقدِّم",
|
||||
next: "التالي",
|
||||
answerPlaceholder: "اكتب إجابتك...",
|
||||
@@ -1332,8 +1334,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "المشروع",
|
||||
base: "الأساس",
|
||||
branch: "الفرع",
|
||||
baseNotApplicable: "غير قابل للتطبيق",
|
||||
host: "المضيف",
|
||||
},
|
||||
titlePlaceholder: "العنوان (اختياري)",
|
||||
errors: {
|
||||
@@ -2103,6 +2106,7 @@ export const ar: TranslationResources = {
|
||||
connections: "اتصالات",
|
||||
agents: "Agents",
|
||||
metadata: "البيانات الوصفية",
|
||||
voice: "الصوت",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
@@ -2125,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: "الإرسال",
|
||||
|
||||
@@ -357,6 +357,7 @@ export const en = {
|
||||
details: "Details",
|
||||
},
|
||||
dictation: {
|
||||
paused: "Microphone interrupted. Waiting to resume…",
|
||||
start: "Start voice dictation",
|
||||
cancel: "Cancel dictation",
|
||||
retry: "Retry dictation",
|
||||
@@ -366,6 +367,7 @@ export const en = {
|
||||
failedRetry: "Dictation failed. Tap retry.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "Could not send your response. Try again.",
|
||||
submit: "Submit",
|
||||
next: "Next",
|
||||
answerPlaceholder: "Type your answer...",
|
||||
@@ -1340,8 +1342,9 @@ export const en = {
|
||||
},
|
||||
fields: {
|
||||
project: "Project",
|
||||
base: "Base",
|
||||
branch: "Branch",
|
||||
baseNotApplicable: "Not applicable",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Title (optional)",
|
||||
errors: {
|
||||
@@ -2166,6 +2169,7 @@ export const en = {
|
||||
connections: "Connections",
|
||||
agents: "Agents",
|
||||
metadata: "Metadata",
|
||||
voice: "Voice",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
@@ -2250,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",
|
||||
|
||||
@@ -365,6 +365,7 @@ export const es: TranslationResources = {
|
||||
details: "Detalles",
|
||||
},
|
||||
dictation: {
|
||||
paused: "Micrófono interrumpido. Esperando para reanudar…",
|
||||
start: "Iniciar dictado de voz",
|
||||
cancel: "Cancelar dictado",
|
||||
retry: "Reintentar el dictado",
|
||||
@@ -374,6 +375,7 @@ export const es: TranslationResources = {
|
||||
failedRetry: "El dictado falló. Toca reintentar.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
|
||||
submit: "Entregar",
|
||||
next: "Siguiente",
|
||||
answerPlaceholder: "Escribe tu respuesta...",
|
||||
@@ -1368,8 +1370,9 @@ export const es: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Proyecto",
|
||||
base: "Base",
|
||||
branch: "Rama",
|
||||
baseNotApplicable: "No aplicable",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Título (opcional)",
|
||||
errors: {
|
||||
@@ -2151,6 +2154,7 @@ export const es: TranslationResources = {
|
||||
connections: "Conexiones",
|
||||
agents: "Agents",
|
||||
metadata: "Metadatos",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
@@ -2173,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",
|
||||
|
||||
@@ -365,6 +365,7 @@ export const fr: TranslationResources = {
|
||||
details: "Détails",
|
||||
},
|
||||
dictation: {
|
||||
paused: "Microphone interrompu. En attente de reprise…",
|
||||
start: "Démarrer la dictée vocale",
|
||||
cancel: "Annuler la dictée",
|
||||
retry: "Relancer la dictée",
|
||||
@@ -374,6 +375,7 @@ export const fr: TranslationResources = {
|
||||
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
|
||||
submit: "Envoyer",
|
||||
next: "Suivant",
|
||||
answerPlaceholder: "Saisissez votre réponse…",
|
||||
@@ -1361,8 +1363,9 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Projet",
|
||||
base: "Base",
|
||||
branch: "Branche",
|
||||
baseNotApplicable: "Non applicable",
|
||||
host: "Hôte",
|
||||
},
|
||||
titlePlaceholder: "Titre (facultatif)",
|
||||
errors: {
|
||||
@@ -2150,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",
|
||||
@@ -2172,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",
|
||||
|
||||
@@ -365,6 +365,7 @@ export const ja: TranslationResources = {
|
||||
details: "詳細",
|
||||
},
|
||||
dictation: {
|
||||
paused: "マイクが中断されました。再開を待っています…",
|
||||
start: "音声入力を開始",
|
||||
cancel: "音声入力をキャンセル",
|
||||
retry: "音声入力を再試行",
|
||||
@@ -374,6 +375,7 @@ export const ja: TranslationResources = {
|
||||
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
|
||||
submit: "送信",
|
||||
next: "次へ",
|
||||
answerPlaceholder: "回答を入力...",
|
||||
@@ -1346,8 +1348,9 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "プロジェクト",
|
||||
base: "ベース",
|
||||
branch: "ブランチ",
|
||||
baseNotApplicable: "該当なし",
|
||||
host: "ホスト",
|
||||
},
|
||||
titlePlaceholder: "タイトル(任意)",
|
||||
errors: {
|
||||
@@ -2124,6 +2127,7 @@ export const ja: TranslationResources = {
|
||||
connections: "接続",
|
||||
agents: "エージェント",
|
||||
metadata: "メタデータ",
|
||||
voice: "音声",
|
||||
workspaces: "ワークスペース",
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
@@ -2146,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: "送信",
|
||||
|
||||
@@ -361,6 +361,7 @@ export const ko: TranslationResources = {
|
||||
details: "세부 정보",
|
||||
},
|
||||
dictation: {
|
||||
paused: "마이크가 중단되었습니다. 재개를 기다리는 중…",
|
||||
start: "음성 받아쓰기 시작",
|
||||
cancel: "받아쓰기 취소",
|
||||
retry: "받아쓰기 다시 시도",
|
||||
@@ -370,6 +371,7 @@ export const ko: TranslationResources = {
|
||||
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
|
||||
submit: "제출",
|
||||
next: "다음",
|
||||
answerPlaceholder: "답변을 입력하세요...",
|
||||
@@ -1339,8 +1341,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "프로젝트",
|
||||
base: "기준",
|
||||
branch: "브랜치",
|
||||
baseNotApplicable: "해당 없음",
|
||||
host: "호스트",
|
||||
},
|
||||
titlePlaceholder: "제목(선택 사항)",
|
||||
errors: {
|
||||
@@ -2113,6 +2116,7 @@ export const ko: TranslationResources = {
|
||||
connections: "연결",
|
||||
agents: "에이전트",
|
||||
metadata: "메타데이터",
|
||||
voice: "음성",
|
||||
workspaces: "워크스페이스",
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
@@ -2135,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: "전송",
|
||||
|
||||
@@ -364,6 +364,7 @@ export const ptBR: TranslationResources = {
|
||||
details: "Detalhes",
|
||||
},
|
||||
dictation: {
|
||||
paused: "Microfone interrompido. Aguardando para retomar…",
|
||||
start: "Iniciar ditado por voz",
|
||||
cancel: "Cancelar ditado",
|
||||
retry: "Tentar ditado novamente",
|
||||
@@ -373,6 +374,7 @@ export const ptBR: TranslationResources = {
|
||||
failedRetry: "Falha no ditado. Toque para tentar novamente.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
|
||||
submit: "Enviar",
|
||||
next: "Próximo",
|
||||
answerPlaceholder: "Digite sua resposta...",
|
||||
@@ -1358,8 +1360,9 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Projeto",
|
||||
base: "Base",
|
||||
branch: "Branch",
|
||||
baseNotApplicable: "Não aplicável",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Título (opcional)",
|
||||
errors: {
|
||||
@@ -2136,6 +2139,7 @@ export const ptBR: TranslationResources = {
|
||||
connections: "Conexões",
|
||||
agents: "Agentes",
|
||||
metadata: "Metadados",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
@@ -2158,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",
|
||||
|
||||
@@ -364,6 +364,7 @@ export const ru: TranslationResources = {
|
||||
details: "Подробности",
|
||||
},
|
||||
dictation: {
|
||||
paused: "Микрофон прерван. Ожидание возобновления…",
|
||||
start: "Начать диктовку голосом",
|
||||
cancel: "Отменить диктовку",
|
||||
retry: "Повторить диктовку",
|
||||
@@ -373,6 +374,7 @@ export const ru: TranslationResources = {
|
||||
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
|
||||
submit: "Отправить",
|
||||
next: "Далее",
|
||||
answerPlaceholder: "Введите ответ...",
|
||||
@@ -1350,8 +1352,9 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Проект",
|
||||
base: "Базовая ветка",
|
||||
branch: "Ветка",
|
||||
baseNotApplicable: "Неприменимо",
|
||||
host: "Хост",
|
||||
},
|
||||
titlePlaceholder: "Заголовок (необязательно)",
|
||||
errors: {
|
||||
@@ -2136,6 +2139,7 @@ export const ru: TranslationResources = {
|
||||
connections: "Подключения",
|
||||
agents: "Агенты",
|
||||
metadata: "Метаданные",
|
||||
voice: "Голос",
|
||||
workspaces: "Рабочие пространства",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
@@ -2158,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: "Отправка",
|
||||
|
||||
@@ -360,6 +360,7 @@ export const zhCN: TranslationResources = {
|
||||
details: "详情",
|
||||
},
|
||||
dictation: {
|
||||
paused: "麦克风已中断,正在等待恢复…",
|
||||
start: "开始语音听写",
|
||||
cancel: "取消听写",
|
||||
retry: "重试听写",
|
||||
@@ -369,6 +370,7 @@ export const zhCN: TranslationResources = {
|
||||
failedRetry: "听写失败。点按重试。",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "未能发送你的回复,请重试。",
|
||||
submit: "提交",
|
||||
next: "下一步",
|
||||
answerPlaceholder: "输入你的回答...",
|
||||
@@ -1321,8 +1323,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "项目",
|
||||
base: "基线",
|
||||
branch: "分支",
|
||||
baseNotApplicable: "不适用",
|
||||
host: "主机",
|
||||
},
|
||||
titlePlaceholder: "标题(可选)",
|
||||
errors: {
|
||||
@@ -2081,6 +2084,7 @@ export const zhCN: TranslationResources = {
|
||||
connections: "连接",
|
||||
agents: "Agents",
|
||||
metadata: "元数据",
|
||||
voice: "语音",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
@@ -2102,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: "发送",
|
||||
|
||||
@@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it.each(["projects", "workspaces"] as const)(
|
||||
"requests projects and workspaces together and commits once when %s responds first",
|
||||
async (firstResponse) => {
|
||||
const serverId = `parallel-directory-${firstResponse}`;
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, projectList: true },
|
||||
});
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const releaseWorkspaces = client.holdWorkspaceFetch();
|
||||
const project = {
|
||||
projectId: "parallel-project",
|
||||
projectDisplayName: "Project baseline",
|
||||
projectRootPath: "/repo/parallel",
|
||||
projectKind: "git" as const,
|
||||
};
|
||||
const projects: ProjectListResult = { requestId: "projects", projects: [project] };
|
||||
const workspaces: WorkspaceFetchResult = {
|
||||
requestId: "workspaces",
|
||||
entries: [createWorkspaceEntry("parallel-workspace")],
|
||||
emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
};
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
try {
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
if (firstResponse === "projects") releaseProjects(projects);
|
||||
else releaseWorkspaces(workspaces);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0);
|
||||
expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0);
|
||||
releaseProjects(projects);
|
||||
releaseWorkspaces(workspaces);
|
||||
await refresh;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session.workspaces.has("parallel-workspace")).toBe(true);
|
||||
expect(session.projects.get(project.projectId)?.projectDisplayName).toBe(
|
||||
"Newer workspace overlay",
|
||||
);
|
||||
} finally {
|
||||
releaseProjects(projects);
|
||||
releaseWorkspaces(workspaces);
|
||||
await refresh;
|
||||
directory.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
|
||||
@@ -563,16 +563,20 @@ export class DirectorySync {
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
const supportsProjectList = serverInfo?.features?.projectList === true;
|
||||
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
||||
if (supportsProjectList) {
|
||||
await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
|
||||
}
|
||||
await this.fetchWorkspaceSnapshot(
|
||||
client,
|
||||
source,
|
||||
transaction,
|
||||
input?.subscribe === true,
|
||||
supportsDirectorySync,
|
||||
);
|
||||
const projectsReady = supportsProjectList
|
||||
? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync)
|
||||
: Promise.resolve();
|
||||
await Promise.all([
|
||||
projectsReady,
|
||||
this.fetchWorkspaceSnapshot(
|
||||
client,
|
||||
source,
|
||||
transaction,
|
||||
input?.subscribe === true,
|
||||
supportsDirectorySync,
|
||||
projectsReady,
|
||||
),
|
||||
]);
|
||||
if (!supportsProjectList) {
|
||||
this.buildLegacyProjectSnapshot(transaction.snapshot);
|
||||
(transaction.snapshot.syncModes ??= {}).projects =
|
||||
@@ -592,6 +596,7 @@ export class DirectorySync {
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
projectsReady: Promise<void>,
|
||||
): Promise<void> {
|
||||
let cursor: string | null = null;
|
||||
let subscribe = initialSubscribe;
|
||||
@@ -618,6 +623,9 @@ export class DirectorySync {
|
||||
});
|
||||
payload = await subscription.ready;
|
||||
} else payload = await client.fetchWorkspaces(query);
|
||||
// Fetch both directories together, but apply the project baseline before the
|
||||
// workspace page's empty-project overlays, regardless of response order.
|
||||
await projectsReady;
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null);
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
|
||||
* `field` is the compact form: a full-width filled box with the field's label inside, above the
|
||||
* value, so the whole field is one tap target.
|
||||
*/
|
||||
export type FieldTriggerVariant = "badge" | "field";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
|
||||
function Chevron(): ReactElement {
|
||||
return (
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const chevron = <Chevron />;
|
||||
|
||||
export interface FieldTriggerContentProps {
|
||||
variant: FieldTriggerVariant;
|
||||
/** The field's name, shown inside a `field`. */
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
value: string;
|
||||
/** Secondary text after the value, such as a terminal profile's name. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function FieldTriggerContent({
|
||||
variant,
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
detail,
|
||||
}: FieldTriggerContentProps): ReactElement {
|
||||
const valueLine = (
|
||||
<>
|
||||
<View style={styles.iconBox}>{icon}</View>
|
||||
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
|
||||
{value}
|
||||
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
if (variant === "badge") {
|
||||
return (
|
||||
<>
|
||||
{valueLine}
|
||||
{chevron}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<View style={styles.fieldBody}>
|
||||
<Text style={styles.fieldLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.fieldValueLine}>{valueLine}</View>
|
||||
</View>
|
||||
{chevron}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
|
||||
export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
|
||||
return useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
variant === "field" ? styles.field : styles.badge,
|
||||
!disabled && Boolean(state.hovered) && styles.highlighted,
|
||||
!disabled &&
|
||||
state.pressed &&
|
||||
(variant === "field" ? styles.highlighted : styles.badgePressed),
|
||||
disabled && styles.disabled,
|
||||
],
|
||||
[disabled, variant],
|
||||
);
|
||||
}
|
||||
|
||||
export interface FieldTriggerProps extends FieldTriggerContentProps {
|
||||
/** What a screen reader announces; defaults to `label`. */
|
||||
accessibilityLabel?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
/** Desktop hover hint; badges only, since a field shows its label. */
|
||||
tooltip?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** A field trigger that anchors a Combobox. */
|
||||
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
|
||||
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const style = useFieldTriggerStyle(content.variant, disabled);
|
||||
const trigger = (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
chevron={null}
|
||||
block={content.variant === "field"}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? content.label}
|
||||
>
|
||||
<FieldTriggerContent {...content} />
|
||||
</ComboboxTrigger>
|
||||
);
|
||||
if (content.variant === "field" || !tooltip) return trigger;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
// Height of a badge. The desktop row sizes from it.
|
||||
export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
|
||||
// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
|
||||
export const FIELD_HEIGHT = 56;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: FIELD_BADGE_HEIGHT,
|
||||
maxWidth: 240,
|
||||
overflow: "hidden",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
field: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: FIELD_HEIGHT,
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
highlighted: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgePressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
// Optical: the chevron's ink sits high in its box.
|
||||
chevron: {
|
||||
flexShrink: 0,
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
value: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fieldBody: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fieldValueLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
fieldValue: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export const folderIcon = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
|
||||
// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
|
||||
// token, so it stays a literal sized to the box.
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
|
||||
export function ProjectIcon({
|
||||
projectViewKey,
|
||||
projectName,
|
||||
iconDataUri,
|
||||
}: {
|
||||
projectViewKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
}): ReactElement {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.fallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fallbackText: {
|
||||
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import type { LaunchTarget } from "@/new-workspace-launch/target";
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { PickerItem } from "../../new-workspace-picker-item";
|
||||
import {
|
||||
FIELD_BADGE_HEIGHT,
|
||||
FIELD_HEIGHT,
|
||||
FieldTrigger,
|
||||
type FieldTriggerVariant,
|
||||
} from "./field-trigger";
|
||||
import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
|
||||
import { LaunchControl } from "./launch-control";
|
||||
import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
|
||||
|
||||
type Isolation = "local" | "worktree";
|
||||
type RenderOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
|
||||
interface PickerControl {
|
||||
anchorRef: RefObject<View | null>;
|
||||
open: () => void;
|
||||
openState: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface NewWorkspaceFormProps {
|
||||
isCompact: boolean;
|
||||
isPending: boolean;
|
||||
project: PickerControl & {
|
||||
options: ComboboxOption[];
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
iconDataByProjectViewKey: Map<string, string | null>;
|
||||
selectedProject: HostProjectListItem | null;
|
||||
selectedOptionId: string;
|
||||
triggerLabel: string;
|
||||
selectedServerId: string;
|
||||
/** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
|
||||
supportsWorkspaceMultiplicity: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onAddProject: () => void;
|
||||
};
|
||||
host: PickerControl & {
|
||||
allHosts: HostProfile[];
|
||||
selectedServerId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onAddHost: () => void;
|
||||
};
|
||||
isolation: PickerControl & {
|
||||
effectiveIsolation: Isolation;
|
||||
canCreateWorktree: boolean;
|
||||
onSelect: (isolation: Isolation) => void;
|
||||
};
|
||||
base: PickerControl & {
|
||||
selectedSourceDirectory: string | null;
|
||||
selectedItem: PickerItem | null;
|
||||
triggerLabel: string;
|
||||
options: ComboboxOption[];
|
||||
itemById: Map<string, PickerItem>;
|
||||
selectedOptionId: string;
|
||||
onSelect: (id: string) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
emptyText: string;
|
||||
showRefPicker: boolean;
|
||||
};
|
||||
launch: {
|
||||
serverId: string;
|
||||
target: LaunchTarget;
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function hostOptionTestID(serverId: string): string {
|
||||
return `new-workspace-host-picker-option-${serverId}`;
|
||||
}
|
||||
|
||||
function isProjectSelectable(
|
||||
project: HostProjectListItem,
|
||||
supportsWorkspaceMultiplicity: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
supportsWorkspaceMultiplicity ||
|
||||
project.hosts.some((host) => host.worktreeSupport !== "unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
function useProjectPicker(
|
||||
project: NewWorkspaceFormProps["project"],
|
||||
isPending: boolean,
|
||||
): { renderOption: RenderOption; footer: ReactNode } {
|
||||
const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
|
||||
const { supportsWorkspaceMultiplicity, onAddProject } = project;
|
||||
const renderOption = useCallback<RenderOption>(
|
||||
(row) => {
|
||||
const item = projectByOptionId.get(row.option.id);
|
||||
return (
|
||||
<ProjectOption
|
||||
{...row}
|
||||
project={item}
|
||||
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
|
||||
selectedServerId={selectedServerId}
|
||||
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
iconDataByProjectViewKey,
|
||||
isPending,
|
||||
projectByOptionId,
|
||||
selectedServerId,
|
||||
supportsWorkspaceMultiplicity,
|
||||
],
|
||||
);
|
||||
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [onAddProject]);
|
||||
return { renderOption, footer };
|
||||
}
|
||||
|
||||
function useIsolationPicker(
|
||||
isolation: NewWorkspaceFormProps["isolation"],
|
||||
isPending: boolean,
|
||||
): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
|
||||
const { t } = useTranslation();
|
||||
const { canCreateWorktree, onSelect, onOpenChange } = isolation;
|
||||
// "New worktree" is omitted entirely (not disabled) when the project isn't a
|
||||
// git checkout, since worktree isolation is impossible there.
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const local = { id: "local", label: t("newWorkspace.isolation.local") };
|
||||
if (!canCreateWorktree) return [local];
|
||||
return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
|
||||
}, [canCreateWorktree, t]);
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id === "worktree" ? "worktree" : "local");
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenChange, onSelect],
|
||||
);
|
||||
const renderOption = useCallback<RenderOption>(
|
||||
(row) => <IsolationOption {...row} disabled={isPending} />,
|
||||
[isPending],
|
||||
);
|
||||
return { options, onSelect: handleSelect, renderOption };
|
||||
}
|
||||
|
||||
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
|
||||
return useCallback<RenderOption>(
|
||||
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
|
||||
[isPending, itemById],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
|
||||
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
|
||||
* that does not apply to the selected project is left out, and Host only shows with a second host.
|
||||
*/
|
||||
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { isCompact, isPending, project, host, isolation, base, launch } = props;
|
||||
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
|
||||
const projectPicker = useProjectPicker(project, isPending);
|
||||
const isolationPicker = useIsolationPicker(isolation, isPending);
|
||||
const renderRefOption = useRefRenderOption(base.itemById, isPending);
|
||||
|
||||
const showHost = host.allHosts.length > 1;
|
||||
const selectedHostLabel =
|
||||
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
||||
const selectedProject = project.selectedProject;
|
||||
const selectedProjectIconUri = selectedProject
|
||||
? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
|
||||
: null;
|
||||
const projectIcon = useMemo(
|
||||
() =>
|
||||
selectedProject ? (
|
||||
<ProjectIcon
|
||||
projectViewKey={selectedProject.viewKey}
|
||||
projectName={selectedProject.projectName}
|
||||
iconDataUri={selectedProjectIconUri}
|
||||
/>
|
||||
) : (
|
||||
folderIcon
|
||||
),
|
||||
[selectedProject, selectedProjectIconUri],
|
||||
);
|
||||
const hostIcon = useMemo(
|
||||
() => <HostStatusDot serverId={host.selectedServerId} />,
|
||||
[host.selectedServerId],
|
||||
);
|
||||
const isWorktree = isolation.effectiveIsolation === "worktree";
|
||||
const isolationValue = isWorktree
|
||||
? t("newWorkspace.isolation.worktree")
|
||||
: t("newWorkspace.isolation.local");
|
||||
const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
|
||||
|
||||
const projectField = (
|
||||
<FieldTrigger
|
||||
ref={project.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.project")}
|
||||
accessibilityLabel="Workspace project"
|
||||
tooltip={t("newWorkspace.tooltips.project")}
|
||||
icon={projectIcon}
|
||||
value={project.triggerLabel}
|
||||
onPress={project.open}
|
||||
disabled={isPending}
|
||||
testID="new-workspace-project-picker-trigger"
|
||||
/>
|
||||
);
|
||||
const hostField = showHost ? (
|
||||
<HostPicker
|
||||
hosts={host.allHosts}
|
||||
value={host.selectedServerId}
|
||||
onSelect={host.onSelect}
|
||||
open={host.openState}
|
||||
onOpenChange={host.onOpenChange}
|
||||
anchorRef={host.anchorRef}
|
||||
includeAddHost={isCompact}
|
||||
onAddHost={host.onAddHost}
|
||||
searchable={false}
|
||||
title={t("newWorkspace.fields.host")}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={200}
|
||||
hostOptionTestID={hostOptionTestID}
|
||||
>
|
||||
<FieldTrigger
|
||||
ref={host.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.host")}
|
||||
tooltip={t("newWorkspace.tooltips.host")}
|
||||
icon={hostIcon}
|
||||
value={selectedHostLabel}
|
||||
onPress={host.open}
|
||||
disabled={isPending}
|
||||
testID="host-picker-trigger"
|
||||
/>
|
||||
</HostPicker>
|
||||
) : null;
|
||||
const isolationField = isolation.canCreateWorktree ? (
|
||||
<FieldTrigger
|
||||
ref={isolation.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.isolation.label")}
|
||||
accessibilityLabel="Workspace isolation"
|
||||
tooltip={t("newWorkspace.tooltips.isolation")}
|
||||
icon={isWorktree ? branchIcon : folderIcon}
|
||||
value={isolationValue}
|
||||
onPress={isolation.open}
|
||||
disabled={isPending}
|
||||
testID="workspace-create-isolation-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const baseField = base.showRefPicker ? (
|
||||
<FieldTrigger
|
||||
ref={base.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.branch")}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltip={t("newWorkspace.tooltips.startingRef")}
|
||||
icon={refIcon}
|
||||
value={base.triggerLabel}
|
||||
onPress={base.open}
|
||||
disabled={isPending || !base.selectedSourceDirectory}
|
||||
testID="new-workspace-ref-picker-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const launchField = (
|
||||
<LaunchControl
|
||||
variant={variant}
|
||||
serverId={launch.serverId}
|
||||
target={launch.target}
|
||||
onChange={launch.onChange}
|
||||
profiles={launch.profiles}
|
||||
disabled={launch.disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const pickers = (
|
||||
<>
|
||||
<Combobox
|
||||
options={project.options}
|
||||
value={project.selectedOptionId}
|
||||
onSelect={project.onSelect}
|
||||
searchable
|
||||
searchPlaceholder="Search projects"
|
||||
title={t("newWorkspace.fields.project")}
|
||||
open={project.openState}
|
||||
onOpenChange={project.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={360}
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={projectPicker.renderOption}
|
||||
footer={projectPicker.footer}
|
||||
/>
|
||||
<Combobox
|
||||
options={isolationPicker.options}
|
||||
value={isolation.effectiveIsolation}
|
||||
onSelect={isolationPicker.onSelect}
|
||||
title={t("newWorkspace.isolation.label")}
|
||||
open={isolation.openState}
|
||||
onOpenChange={isolation.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={isolation.anchorRef}
|
||||
renderOption={isolationPicker.renderOption}
|
||||
/>
|
||||
<Combobox
|
||||
options={base.options}
|
||||
value={base.selectedOptionId}
|
||||
onSelect={base.onSelect}
|
||||
searchable
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={base.openState}
|
||||
onOpenChange={base.onOpenChange}
|
||||
onSearchQueryChange={base.setSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={base.anchorRef}
|
||||
emptyText={base.emptyText}
|
||||
renderOption={renderRefOption}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
|
||||
{projectField}
|
||||
{hostField}
|
||||
{isolationField}
|
||||
{baseField}
|
||||
{launchField}
|
||||
{/* Isolation and Base come and go with the project; their rows stay reserved so the
|
||||
stack keeps one height and nothing above the composer jumps. */}
|
||||
{isolationField ? null : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
|
||||
<View style={styles.desktopControl}>{projectField}</View>
|
||||
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
|
||||
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
||||
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||
{launchField}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compactStack: {
|
||||
marginBottom: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
reservedField: {
|
||||
height: FIELD_HEIGHT,
|
||||
},
|
||||
desktopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: FIELD_BADGE_HEIGHT,
|
||||
// Matches the gap between the composer and the Import session pill below it.
|
||||
marginBottom: theme.spacing[4],
|
||||
// The badge adds its own left padding; offset it so the project icon's left
|
||||
// edge lands exactly on the "New workspace" title's left edge. The trailing
|
||||
// inset mirrors it so the launch chip stops on the composer's inner content
|
||||
// rather than running out to the composer's border.
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
desktopControl: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
// Pushes the launch control to the trailing edge of the desktop meta row.
|
||||
launchSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
+26
-46
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
|
||||
import { MessageCircle, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -30,14 +30,17 @@ import {
|
||||
resolveLaunchProfile,
|
||||
terminalLaunchTarget,
|
||||
type LaunchTarget,
|
||||
} from "./target";
|
||||
} from "@/new-workspace-launch/target";
|
||||
import {
|
||||
FieldTriggerContent,
|
||||
useFieldTriggerStyle,
|
||||
type FieldTriggerVariant,
|
||||
} from "./field-trigger";
|
||||
|
||||
const ThemedMessageCircle = withUnistyles(MessageCircle);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The meta row's shared badge style. Passed in rather than redeclared so this
|
||||
* trigger stays pixel-identical to the project, host, and branch triggers it
|
||||
* sits beside; a local copy drifts the moment one of them changes.
|
||||
*/
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
variant: FieldTriggerVariant;
|
||||
}
|
||||
|
||||
function TriggerIcon({
|
||||
@@ -108,8 +106,8 @@ function TriggerIcon({
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta-row control choosing what New workspace submits to: the chat agent,
|
||||
* or a terminal. One trigger shaped like every other chip on that row, opening
|
||||
* The form control choosing what New workspace submits to: the chat agent,
|
||||
* or a terminal. One trigger shaped like every other field trigger, opening
|
||||
* a menu whose Chat and Terminal groups are what keep a terminal profile named
|
||||
* "Codex" from reading as a chat Codex agent.
|
||||
*/
|
||||
@@ -119,13 +117,18 @@ export function LaunchControl({
|
||||
onChange,
|
||||
profiles,
|
||||
disabled = false,
|
||||
badgePressableStyle,
|
||||
variant,
|
||||
}: LaunchControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
|
||||
const triggerStyle = useFieldTriggerStyle(variant, disabled);
|
||||
const triggerIcon = useMemo(
|
||||
() => <TriggerIcon target={target} profile={selectedProfile} />,
|
||||
[selectedProfile, target],
|
||||
);
|
||||
|
||||
// A profile's own name and logo say nothing about where it runs: a row
|
||||
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
|
||||
@@ -149,32 +152,26 @@ export function LaunchControl({
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
|
||||
{/* The trigger is the Pressable itself, styled by the meta row's shared
|
||||
badge style, so it lands pixel-identical to the project, host, and
|
||||
branch chips it sits beside. */}
|
||||
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
|
||||
tooltip anchors to a wrapping View instead. Suppressed while the menu
|
||||
is open so the two surfaces never overlap. */}
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
testID="new-workspace-launch-trigger"
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("newWorkspace.tooltips.launch")}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<TriggerIcon target={target} profile={selectedProfile} />
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{triggerLabel}
|
||||
{selectedProfile ? (
|
||||
<Text style={styles.profileName}> {selectedProfile.name}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
|
||||
<FieldTriggerContent
|
||||
variant={variant}
|
||||
label={t("newWorkspace.launch.title")}
|
||||
icon={triggerIcon}
|
||||
value={triggerLabel}
|
||||
detail={selectedProfile?.name}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
@@ -232,27 +229,10 @@ export function LaunchControl({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
profileName: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
command: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
|
||||
import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
|
||||
import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
|
||||
|
||||
interface OptionRowState {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function IconBox({ children }: { children: ReactElement }): ReactElement {
|
||||
return <View style={styles.iconBox}>{children}</View>;
|
||||
}
|
||||
|
||||
export function ProjectOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
project,
|
||||
iconDataUri,
|
||||
selectedServerId,
|
||||
disabled,
|
||||
}: OptionRowState & {
|
||||
project: HostProjectListItem | undefined;
|
||||
iconDataUri: string | null;
|
||||
selectedServerId: string;
|
||||
disabled: boolean;
|
||||
}): ReactElement {
|
||||
const leadingSlot = useMemo(
|
||||
() =>
|
||||
project ? (
|
||||
<IconBox>
|
||||
<ProjectIcon
|
||||
projectViewKey={project.viewKey}
|
||||
projectName={project.projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
/>
|
||||
</IconBox>
|
||||
) : undefined,
|
||||
[iconDataUri, project],
|
||||
);
|
||||
if (!project) return <View key={option.id} />;
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`new-workspace-project-picker-option-${project.viewKey}`}
|
||||
label={project.projectName}
|
||||
description={
|
||||
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const openProjectKeys = useShortcutKeys("new-agent");
|
||||
const shortcut = useMemo(
|
||||
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
|
||||
[openProjectKeys],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID="new-workspace-project-picker-add-project"
|
||||
label={t("sidebar.actions.addProject")}
|
||||
onPress={onPress}
|
||||
leadingSlot={addProjectIcon}
|
||||
trailingSlot={shortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const localLeading = <IconBox>{folderIcon}</IconBox>;
|
||||
|
||||
export function IsolationOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
disabled,
|
||||
}: OptionRowState & { disabled: boolean }): ReactElement {
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`workspace-create-isolation-${option.id}`}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const branchLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
|
||||
|
||||
export function RefOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
item,
|
||||
disabled,
|
||||
}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
divergenceLabel ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
|
||||
[divergenceLabel],
|
||||
);
|
||||
if (!item) return <View key={option.id} />;
|
||||
|
||||
const isBranch = item.kind === "branch";
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={
|
||||
isBranch
|
||||
? `new-workspace-ref-picker-branch-${item.name}`
|
||||
: `new-workspace-ref-picker-pr-${item.item.number}`
|
||||
}
|
||||
label={pickerItemLabel(item)}
|
||||
description={
|
||||
!isBranch && item.item.baseRefName
|
||||
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
|
||||
: undefined
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
|
||||
trailingSlot={trailingSlot}
|
||||
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
divergenceLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
}));
|
||||
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* The new workspace screen's way into Import session: a ghost button at the top on compact
|
||||
* The new workspace screen's way into Import session: a ghost button in the header on compact
|
||||
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
|
||||
*/
|
||||
export function ImportSessionButton({
|
||||
@@ -17,7 +17,7 @@ export function ImportSessionButton({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={compact ? styles.compact : styles.wide}>
|
||||
<View style={compact ? null : styles.wide}>
|
||||
<Button
|
||||
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
|
||||
variant="ghost"
|
||||
@@ -34,9 +34,6 @@ export function ImportSessionButton({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compact: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
|
||||
// border lines up with the composer's border.
|
||||
wide: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
|
||||
const WINDOW_CONTENT_DATASET = { "window-content": "" };
|
||||
|
||||
export function OpenProjectScreen() {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
@@ -61,51 +62,56 @@ export function OpenProjectScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={52} />
|
||||
</View>
|
||||
{hosts.map((host) => (
|
||||
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
<View style={styles.tiles}>
|
||||
<HomeTile
|
||||
icon={FolderOpen}
|
||||
title={t("openProject.tiles.addProject.title")}
|
||||
description={t("openProject.tiles.addProject.description")}
|
||||
onPress={handleOpenPicker}
|
||||
testID="open-project-submit"
|
||||
accent
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Inbox}
|
||||
title={t("openProject.tiles.importSession.title")}
|
||||
description={t("openProject.tiles.importSession.description")}
|
||||
onPress={importSession.open}
|
||||
testID="open-project-import-session"
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Plug}
|
||||
title={t("openProject.tiles.setupProviders.title")}
|
||||
description={t("openProject.tiles.setupProviders.description")}
|
||||
onPress={handleOpenProviders}
|
||||
testID="open-project-setup-providers"
|
||||
/>
|
||||
{localServerId ? (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
dataSet={WINDOW_CONTENT_DATASET}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={52} />
|
||||
</View>
|
||||
{hosts.map((host) => (
|
||||
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
<View style={styles.tiles}>
|
||||
<HomeTile
|
||||
icon={Smartphone}
|
||||
title={t("openProject.tiles.pairDevice.title")}
|
||||
description={t("openProject.tiles.pairDevice.description")}
|
||||
onPress={handleOpenPairDevice}
|
||||
testID="open-project-pair-device"
|
||||
icon={FolderOpen}
|
||||
title={t("openProject.tiles.addProject.title")}
|
||||
description={t("openProject.tiles.addProject.description")}
|
||||
onPress={handleOpenPicker}
|
||||
testID="open-project-submit"
|
||||
accent
|
||||
/>
|
||||
) : null}
|
||||
<HomeTile
|
||||
icon={Inbox}
|
||||
title={t("openProject.tiles.importSession.title")}
|
||||
description={t("openProject.tiles.importSession.description")}
|
||||
onPress={importSession.open}
|
||||
testID="open-project-import-session"
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Plug}
|
||||
title={t("openProject.tiles.setupProviders.title")}
|
||||
description={t("openProject.tiles.setupProviders.description")}
|
||||
onPress={handleOpenProviders}
|
||||
testID="open-project-setup-providers"
|
||||
/>
|
||||
{localServerId ? (
|
||||
<HomeTile
|
||||
icon={Smartphone}
|
||||
title={t("openProject.tiles.pairDevice.title")}
|
||||
description={t("openProject.tiles.pairDevice.description")}
|
||||
onPress={handleOpenPairDevice}
|
||||
testID="open-project-pair-device"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.communityRow}>
|
||||
<CommunityLinks />
|
||||
</View>
|
||||
<View style={styles.communityRow}>
|
||||
<CommunityLinks />
|
||||
</View>
|
||||
</ScrollView>
|
||||
<PairDeviceModal
|
||||
serverId={localServerId ?? ""}
|
||||
visible={isPairDeviceOpen}
|
||||
@@ -176,18 +182,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
userSelect: "none",
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
content: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
justifyContent: { xs: "flex-start", md: "center" },
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
padding: theme.spacing[6],
|
||||
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
|
||||
paddingBottom: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
marginBottom: theme.spacing[8],
|
||||
@@ -238,10 +247,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 18,
|
||||
},
|
||||
communityRow: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: {
|
||||
flexShrink: 0,
|
||||
paddingBottom: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
|
||||
md: HEADER_INNER_HEIGHT + theme.spacing[2],
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user