mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 16:52:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79de2540fd | ||
|
|
213c78e819 | ||
|
|
941c890744 | ||
|
|
e109b866ab |
+95
-167
@@ -8,13 +8,9 @@ on:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref cancels the run still in progress, main included.
|
||||
# A full run costs about 200 runner-minutes against the org's 20 concurrent
|
||||
# jobs, and a superseded main run only answers a question the newer commit
|
||||
# answers again.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -58,10 +54,10 @@ jobs:
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
quality:
|
||||
name: quality
|
||||
format:
|
||||
name: format
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -72,67 +68,36 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
id: install
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
# Explicit status checks keep independent domains running after a failure.
|
||||
# A failed step still fails quality; no continue-on-error or success shim.
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
run: npm run format:check
|
||||
|
||||
lint:
|
||||
name: lint
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Lint lockfile
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Test lint rules
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: node --test scripts/oxlint/paseo-plugin.test.mjs
|
||||
|
||||
- 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
|
||||
@@ -149,9 +114,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Verify dependency patch recovery
|
||||
run: node --test scripts/postinstall-patches.test.mjs
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -181,56 +143,43 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &server_capacity
|
||||
name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- &server_checkout
|
||||
uses: actions/checkout@v4
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- &server_fetch
|
||||
name: Fetch origin/main (worktree tests)
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- &server_agent_clis
|
||||
name: Install agent CLIs for provider tests
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- &server_build
|
||||
name: Build server dependencies
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env: &server_test_env
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- &server_plugin_tests
|
||||
name: Run built-in plugin tests
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- &server_hub_contract
|
||||
name: Run Hub CLI/daemon contract
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -239,41 +188,7 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server unit tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
|
||||
- *server_plugin_tests
|
||||
- *server_hub_contract
|
||||
|
||||
# Keep the serial integration chain off the Windows unit critical path.
|
||||
server-integration-windows:
|
||||
name: server-integration (windows-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server integration tests
|
||||
run: npm run test:integration --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
@@ -417,19 +332,52 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
name: sdk-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
name: playwright (shard 1/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
- name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -457,11 +405,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
# Share of the test list per shard, rebalanced from the Playwright step
|
||||
# minutes of five eight-shard runs so the slowest and fastest shards meet.
|
||||
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
|
||||
# Balance contiguous file groups using CI durations and the trimmed
|
||||
# inventory. Required check names and worker counts stay unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "187:183:184:201"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -474,74 +420,56 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/8)
|
||||
name: playwright (shard 2/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/8)
|
||||
name: playwright (shard 3/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/8)
|
||||
name: playwright (shard 4/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
playwright-6:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
steps: *playwright_test_steps
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
playwright-7:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
steps: *playwright_test_steps
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
playwright-8:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
steps: *playwright_test_steps
|
||||
- name: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
-112
@@ -5,7 +5,6 @@
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
@@ -357,117 +356,6 @@
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "error"
|
||||
}
|
||||
},
|
||||
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
|
||||
// 94 files. They may only leave this list.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/ui/**/*.{ts,tsx}",
|
||||
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
|
||||
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/attachment-pill.tsx",
|
||||
"packages/app/src/components/combined-model-selector.tsx",
|
||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/desktop/window-controls.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/file-explorer-pane.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/highlighted-code-block.tsx",
|
||||
"packages/app/src/components/hosts/host-filter.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/import-session-sheet.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
|
||||
"packages/app/src/components/message.tsx",
|
||||
"packages/app/src/components/model-browser.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||
"packages/app/src/components/schedules/schedule-row.tsx",
|
||||
"packages/app/src/components/settings/collapsible-row.tsx",
|
||||
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
|
||||
"packages/app/src/components/sidebar-callout.tsx",
|
||||
"packages/app/src/components/sidebar-resize-handle.tsx",
|
||||
"packages/app/src/components/sidebar-workspace-list.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
|
||||
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
|
||||
"packages/app/src/components/terminal-copy-paste-actions.tsx",
|
||||
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/components/workspace-hover-card.tsx",
|
||||
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
|
||||
"packages/app/src/composer/agent-controls/index.tsx",
|
||||
"packages/app/src/composer/diff-stat-pill.tsx",
|
||||
"packages/app/src/composer/dock/internal/background.native.tsx",
|
||||
"packages/app/src/composer/draft/import-pill.tsx",
|
||||
"packages/app/src/composer/index.tsx",
|
||||
"packages/app/src/composer/tracks.tsx",
|
||||
"packages/app/src/desktop/browser/pane/index.electron.tsx",
|
||||
"packages/app/src/git/actions-split-button.tsx",
|
||||
"packages/app/src/git/commits-section/commit-row.tsx",
|
||||
"packages/app/src/git/commits-section/commits-section.tsx",
|
||||
"packages/app/src/git/diff-pane.tsx",
|
||||
"packages/app/src/git/forges/gitlab.view.tsx",
|
||||
"packages/app/src/git/pull-request-panel/checks-section.tsx",
|
||||
"packages/app/src/git/pull-request-panel/pane.tsx",
|
||||
"packages/app/src/git/pull-request-panel/section-kit.tsx",
|
||||
"packages/app/src/hosts/host-chooser.tsx",
|
||||
"packages/app/src/mobile-panels/presentation.tsx",
|
||||
"packages/app/src/panels/new-tab-panel.tsx",
|
||||
"packages/app/src/panels/setup-panel.tsx",
|
||||
"packages/app/src/plugins/buttons/view.tsx",
|
||||
"packages/app/src/review/surface.tsx",
|
||||
"packages/app/src/screens/open-project-screen.tsx",
|
||||
"packages/app/src/screens/project-settings-screen.tsx",
|
||||
"packages/app/src/screens/projects-screen.tsx",
|
||||
"packages/app/src/screens/settings-screen.tsx",
|
||||
"packages/app/src/screens/settings/host-appearance-section.tsx",
|
||||
"packages/app/src/screens/settings/host-page.tsx",
|
||||
"packages/app/src/screens/settings/providers-section.tsx",
|
||||
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
|
||||
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
|
||||
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
|
||||
"packages/app/src/usage/sidebar-item.tsx",
|
||||
"packages/app/src/usage/window-bar.tsx",
|
||||
"packages/app/src/workspace-labels/manager-modal.tsx",
|
||||
"packages/app/src/workspace-labels/swatch.tsx",
|
||||
"packages/app/src/workspace/open-in-editor/button.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/e2e/**/fixtures.ts"],
|
||||
"rules": {
|
||||
|
||||
+5
-43
@@ -1,8 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0 - 2026-10-19
|
||||
## 0.12.0-beta.1 - 2026-10-16
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
|
||||
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
@@ -27,15 +27,8 @@ 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 Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
|
||||
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
|
||||
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
|
||||
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
|
||||
- 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), [#7342](https://github.com/getpaseo/paseo/pull/7342))
|
||||
- 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
|
||||
@@ -44,10 +37,6 @@ 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 follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
|
||||
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
|
||||
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -61,29 +50,16 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
|
||||
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
|
||||
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
|
||||
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
|
||||
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
|
||||
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) 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 new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
|
||||
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
|
||||
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
|
||||
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
|
||||
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
|
||||
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
|
||||
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
|
||||
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
|
||||
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
|
||||
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
|
||||
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
|
||||
- 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))
|
||||
@@ -104,24 +80,15 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
|
||||
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
|
||||
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
|
||||
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
|
||||
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
|
||||
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
|
||||
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
|
||||
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
|
||||
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
|
||||
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
|
||||
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
|
||||
- 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 or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
|
||||
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
|
||||
@@ -141,7 +108,6 @@ 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))
|
||||
@@ -166,11 +132,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
|
||||
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
|
||||
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
|
||||
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
|
||||
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
|
||||
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
|
||||
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
|
||||
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
|
||||
+1
-2
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -249,7 +249,6 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
|
||||
+1
-3
@@ -535,9 +535,7 @@ For tighter loops, you can rebuild a single workspace:
|
||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||
patch-package cannot apply a patch that changed after an earlier version was applied, so the
|
||||
script removes that package and fails the install; the next `npm install` reinstalls it clean
|
||||
with the current patch. Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
@@ -4,10 +4,6 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
|
||||
|
||||
## Block spacing
|
||||
|
||||
Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
@@ -22,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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+9
-27
@@ -240,9 +240,8 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/reference.md#host-ui).
|
||||
@@ -333,7 +332,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
@@ -401,25 +400,6 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
### Plugin icons
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
|
||||
`icon` is a Lucide name, `{ svg }`, or a component.
|
||||
|
||||
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
|
||||
The daemon applies it to provider and usage-source icon files; the app applies it to inline
|
||||
icons. Change the rules there, never at a call site.
|
||||
- The app validates each icon once, at registration, through `parsePluginIcon` in
|
||||
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
|
||||
- That module is the only code that branches on the icon form. Surfaces draw with
|
||||
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
|
||||
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
|
||||
component icon never reach an SVG element.
|
||||
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
|
||||
each attachment, and a component cannot be stored.
|
||||
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
|
||||
markup string.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
@@ -511,13 +491,15 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
|
||||
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
|
||||
string is never inline SVG or a URL.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
|
||||
@@ -74,14 +74,6 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
|
||||
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
||||
for parsing compatibility; modern requests cannot select their subscription ID.
|
||||
|
||||
Workspace setup details negotiate `workspace_setup_observation` with
|
||||
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
|
||||
the legacy session event category. The setup panel owns a workspace-scoped observation while
|
||||
active and visible; directory metadata signals failures without requesting output. Older clients
|
||||
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
|
||||
requires a host update to view details. Subscription-capable older hosts honor that absence of
|
||||
demand; broadcast-era hosts require an update before wire silence is guaranteed.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
@@ -154,6 +154,8 @@ npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
- Use one browser test per causal user journey, with named `test.step()` blocks for behaviors reached along the way. Reuse the open form, workspace, and browser state. Start another test for a distinct initial state, failure injection, race ordering, viewport, or destructive branch.
|
||||
- Keep pure test-helper checks in `packages/app/e2e/support/helpers/*.test.ts`; the app unit job runs them without Metro, a daemon, or a browser.
|
||||
|
||||
### File naming
|
||||
|
||||
|
||||
@@ -251,8 +251,7 @@ do not imply local ownership or authorization. Capture the sender title at deliv
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
|
||||
labels for every Paseo MCP tool. The daemon
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36349,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37469,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/server": "0.12.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"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",
|
||||
"@getpaseo/highlight": "0.12.0",
|
||||
"@getpaseo/plugin": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"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",
|
||||
"version": "0.12.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1296.0)
|
||||
aws-sdk-core (3.257.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
rexml (~> 3.4, >= 3.4.2)
|
||||
aws-sdk-kms (1.132.0)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.233.2)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.7.20261007)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.3)
|
||||
logger
|
||||
faraday (2.15.0)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.5.0)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.241.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
erb (>= 4.0, < 7.0)
|
||||
excon (>= 1.0.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.5.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 3.1.0, < 4.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.109.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.3.1)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.69.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.4.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.1)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.1)
|
||||
googleauth (1.17.5)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.2)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.10.0)
|
||||
net-protocol (>= 0.3.0)
|
||||
uri (>= 0.11.1)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.3.1)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.1)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (3.1.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.3.0)
|
||||
unicode-emoji (~> 4.3)
|
||||
unicode-emoji (4.3.0)
|
||||
uri (1.1.2)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (~> 2.234)
|
||||
multi_json
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
|
||||
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
|
||||
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
|
||||
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
||||
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
|
||||
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
|
||||
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
|
||||
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
|
||||
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
|
||||
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
|
||||
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
|
||||
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
||||
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
||||
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.23
|
||||
@@ -460,7 +460,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
@@ -549,7 +549,7 @@ async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
|
||||
title: "Submission missed running transition",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
@@ -700,7 +700,7 @@ async function expectCanonicalOrderWinsAcrossOverlappingClients(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
|
||||
title: "Cross-client submission order",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
const localPrompt = "Send this after the other client turn.";
|
||||
const remotePrompt = "Commit this other client turn first.";
|
||||
|
||||
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -36,15 +36,25 @@ test.describe("Host agent skills", () => {
|
||||
page,
|
||||
skills,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const custom = skills.available.slice(0, 2);
|
||||
const cancelledAddition = skills.available[2]!;
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await test.step("the desktop sheet gives the bundled label its intended inset", async () => {
|
||||
await expectBundledSkillsInset(page);
|
||||
});
|
||||
await chooseCustomSkills(page, custom);
|
||||
await saveSkillSelection(page);
|
||||
await expectInstalledSkills(skills, custom);
|
||||
await expectSavedSelection(skills, { mode: "custom", skills: custom });
|
||||
await test.step("installation ignores agent config environment overrides", async () => {
|
||||
await expect(access(`${skills.home}/../ignored-claude-config/skills`)).rejects.toThrow(
|
||||
"ENOENT",
|
||||
);
|
||||
await expect(access(`${skills.home}/../ignored-codex-home/skills`)).rejects.toThrow("ENOENT");
|
||||
});
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, cancelledAddition);
|
||||
@@ -150,23 +160,6 @@ test.describe("Host agent skills", () => {
|
||||
await expectInstalledSkills(skills, secondSelection.skills);
|
||||
await expectSavedSelection(skills, secondSelection);
|
||||
});
|
||||
|
||||
test("ignores agent config environment overrides", async ({ skills }) => {
|
||||
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
|
||||
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-claude-config/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-codex-home/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
@@ -192,24 +185,14 @@ test.describe("Agent skills sheet inset", () => {
|
||||
await openSkillSelection(page);
|
||||
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("desktop", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
await openSkillSelection(page);
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
await expectBundledSkillsInset(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectBundledSkillsInset(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect } from "@playwright/test";
|
||||
import {
|
||||
findScrollJumps,
|
||||
expectImageSpaceReserved,
|
||||
type ScrollFrame,
|
||||
observeTimelinePages,
|
||||
openOnlyTimelineTail,
|
||||
recordUpwardTraversal,
|
||||
@@ -14,179 +12,6 @@ import {
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
|
||||
@@ -51,7 +51,9 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
test("non-UI archive prunes open tabs and keeps the session archived when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -65,28 +67,51 @@ test.describe("Archive tab reconciliation", () => {
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await test.step("open both sessions in two pages", async () => {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
});
|
||||
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
|
||||
await test.step("prune the archived tab across both pages and reload", async () => {
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
|
||||
await test.step("reopen the archived session without unarchiving it", async () => {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
@@ -123,35 +148,4 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await expectLiveAssistantText(page, visibleAfterFork);
|
||||
});
|
||||
|
||||
test("focuses a forked assistant turn in a new workspace draft tab", async ({
|
||||
test("focuses an existing-workspace fork draft and keeps its attachment after submission", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
@@ -101,55 +101,44 @@ test.describe("Assistant fork menu", () => {
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await test.step("complete a second assistant turn in the visible source tab", async () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
|
||||
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-submit-",
|
||||
title: "Assistant fork tab submit",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
|
||||
model: "ten-second-stream",
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await test.step("fork the latest turn into the selected draft tab", async () => {
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await expectChatHistoryAttachment(page);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
|
||||
await submitMessage(page, "");
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
await test.step("submit that draft with its chat-history attachment", async () => {
|
||||
await submitMessage(page, "");
|
||||
|
||||
const userMessage = page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
|
||||
@@ -262,15 +262,54 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
test("clean Changes opens committed changes and navigates an empty comparison", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
await test.step("Changes opens the populated committed comparison for a clean checkout", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("an empty Changes comparison links to the populated comparison", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("Jump to file stays out of the desktop diff and of an empty comparison", async () => {
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
@@ -297,29 +336,6 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -335,223 +351,109 @@ test("Changes comparison controls the working diff and tree selection focuses it
|
||||
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
test("horizontal diff scrolling preserves its header and offset across collapse", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("canvas file headers select without toggling for context menu and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("every interactive file header has the same hover feedback", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
await test.step("horizontal body scrolling never moves or repaints the canvas header", async () => {
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
await test.step("collapsing and expanding restores the real horizontal scroll offset", async () => {
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect.poll(() => readHorizontalDiffOffset(page)).toBe(retainedOffset);
|
||||
});
|
||||
});
|
||||
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
test("file headers scroll with the document and hand off without gaps", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
await test.step("in-flow file headers move with the diff document", async () => {
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return [14, 16].map(
|
||||
(y) =>
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
await test.step("the outgoing sticky header hands off without a gap or overlap", async () => {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const headerAt = (y: number) =>
|
||||
document
|
||||
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||
);
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null;
|
||||
return [headerAt(14), headerAt(16)];
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes tree aligns every file status after its diff stat", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the scrolling diff lists files in changes tree order", async ({ page }) => {
|
||||
test("browse the ordered changes tree and recursively collapse its folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeNestedFolders: true,
|
||||
includeRootFileSortingFirst: true,
|
||||
@@ -559,72 +461,70 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
await test.step("the scrolling diff lists files in changes tree order", async () => {
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await test.step("changes context menu recursively collapses descendant folders", async () => {
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
@@ -761,70 +661,25 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
test("inspect, review, open, and duplicate changed files in the workspace", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await test.step("workspace controls update the comparison and presentation", async () => {
|
||||
await inspectChangesWorkspaceControls(page, workspace);
|
||||
});
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await scrollDiffToTop(page);
|
||||
await reviewChangedLine(page);
|
||||
await scrollDiffToTop(page);
|
||||
await inspectChangedFileHeaders(page);
|
||||
await test.step("duplicate the file and its folder from Changes", async () => {
|
||||
await page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).click();
|
||||
await duplicateChangedFiles(page, workspace);
|
||||
});
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
@@ -890,18 +745,6 @@ test("compact Changes jumps to a file from the changed-files sheet", async ({ pa
|
||||
});
|
||||
});
|
||||
|
||||
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
|
||||
const committed = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, committed, 90_000);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, committed);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1013,33 +856,6 @@ test("changes diff waits for configured fonts, then applies code typography chan
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1058,21 +874,6 @@ test("split canvas creates a review on the changed side and keeps it in that col
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
|
||||
test("scrolling clears the hovered review affordance", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1120,33 +921,6 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1297,23 +1071,6 @@ test("dragging within one wide grapheme or outside its cell never opens a review
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400));
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft),
|
||||
)
|
||||
.toBe(retainedOffset);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
@@ -2218,3 +1975,242 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
});
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
async function readHorizontalDiffOffset(page: Page): Promise<number> {
|
||||
return page
|
||||
.getByTestId("diff-file-0-horizontal-scroll")
|
||||
.evaluate((element) => element.scrollLeft);
|
||||
}
|
||||
|
||||
async function inspectChangesWorkspaceControls(
|
||||
page: Page,
|
||||
workspace: DirtyWorkspace,
|
||||
): Promise<void> {
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
}
|
||||
|
||||
async function reviewChangedLine(page: Page): Promise<void> {
|
||||
await test.step("the whole reviewable row reveals the gutter affordance and uses a text cursor", async () => {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
await test.step("creates, cancels, edits, and deletes a review while keeping Changes focused", async () => {
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("scrolling clears the hovered review affordance", async () => {
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectChangedFileHeaders(page: Page): Promise<void> {
|
||||
await test.step("file statuses follow their diff stats", async () => {
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
await test.step("every interactive file header has the same hover feedback", async () => {
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("canvas file headers select without toggling for context menu and long press", async () => {
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("changes file actions open below the right-click without a reserved kebab", async () => {
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
async function duplicateChangedFiles(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
}
|
||||
|
||||
async function scrollDiffToTop(page: Page): Promise<void> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import {
|
||||
composerLocator,
|
||||
fillComposerDraft,
|
||||
@@ -20,20 +21,18 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
expectPromptsDrawnByPlugin,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_RESPONSE = [
|
||||
const STREAMED_TAIL = "STREAMED-REPLY-FINAL-TAIL";
|
||||
const HYDRATED_PARTIAL = "Hydrated partial continuation";
|
||||
const STREAMED_PREFIX = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].join("\n\n");
|
||||
].map((paragraph) => `${paragraph}\n\n`);
|
||||
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
@@ -125,49 +124,6 @@ async function closeFind(page: Page) {
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
|
||||
// The scrolling surface has no accessible name; use its existing test id.
|
||||
const viewport = page.getByTestId("agent-chat-scroll");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const before = await viewport.evaluate((element) => element.scrollTop);
|
||||
await page.keyboard.press(key);
|
||||
const direction = key === "PageUp" ? -1 : 1;
|
||||
await expect
|
||||
.poll(
|
||||
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
|
||||
)
|
||||
.toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-scroll-focus-",
|
||||
title: "Chat Find keyboard scrolling",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await closeFind(page);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
|
||||
await expectChatKeyboardScroll(page, "PageDown");
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await page.getByRole("button", { name: "Close Find", exact: true }).click();
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -222,7 +178,7 @@ async function highlightedMessage(page: Page) {
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
@@ -267,34 +223,6 @@ test("counts and steps through every match in the chat, not just the selected me
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-plugin-prompt-",
|
||||
title: "Chat Find plugin prompt",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await openChatFind(page);
|
||||
await query(page).fill("scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "plugin-prompt");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -358,50 +286,90 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
interface StreamedReplyChat {
|
||||
cleanup(): Promise<void>;
|
||||
advancePastVisiblePrefix(): Promise<void>;
|
||||
expectHydratedPartial(): Promise<void>;
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
/** The provider owns the pause, so both search and hydration see incomplete output. */
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
const stream = await createControlledStream();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "chat-find-live-blocks-" }).catch(
|
||||
async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
};
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: stream.provider,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Chat Find live blocks",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await workspace.client.sendAgentMessage(agent.id, "Stream a multi block answer");
|
||||
await stream.append(STREAMED_PREFIX);
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible();
|
||||
return {
|
||||
...agent,
|
||||
cleanup,
|
||||
advancePastVisiblePrefix: async () => {
|
||||
const count = gate.getAgentStreamItemCount("assistant_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await stream.append([HYDRATED_PARTIAL]);
|
||||
await gate.waitForAgentStreamItem("assistant_message", count + 1);
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(
|
||||
HYDRATED_PARTIAL,
|
||||
);
|
||||
},
|
||||
expectHydratedPartial: async () => {
|
||||
// A newer daemon row forces Find to fetch the real timeline instead of
|
||||
// matching only the browser's streamed prefix.
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(HYDRATED_PARTIAL);
|
||||
expect(gate.getTimelineRequestCount("before", agent.id)).toBeGreaterThan(0);
|
||||
},
|
||||
replyFinished: async () => {
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
await stream.finish([`\n\n${STREAMED_TAIL}`]);
|
||||
await workspace.client.waitForFinish(agent.id, 15_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectIncompleteStreamedReply(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("beta-needle");
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("filler399");
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(STREAMED_TAIL);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.advancePastVisiblePrefix();
|
||||
await openChatFind(page);
|
||||
// The first-paragraph hit must remain searchable while the same message grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.expectHydratedPartial();
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.replyFinished();
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(STREAMED_TAIL);
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two paragraphs, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import {
|
||||
clickChatOutlineRowEdge,
|
||||
@@ -50,6 +50,43 @@ import {
|
||||
const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
async function navigateLoadedOutline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await test.step("preview across the row", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("jump, dismiss preview, and retain bare selection", async () => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("track the active prompt while jumping and scrolling", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
await test.step("clamp the newest prompt at maximum scroll", async () => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
}
|
||||
|
||||
async function jumpToNewerLoadedPrompt(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
}
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
|
||||
page,
|
||||
@@ -183,28 +220,14 @@ test.describe("desktop chat outline", () => {
|
||||
await expectChatOutlinePrompts(page, LOADED_TURNS);
|
||||
});
|
||||
|
||||
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
test("previews, jumps, and tracks the reader through loaded prompts", async ({ page }) => {
|
||||
await navigateLoadedOutline(page, agent);
|
||||
});
|
||||
|
||||
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
|
||||
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
|
||||
});
|
||||
|
||||
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
|
||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
@@ -215,32 +238,6 @@ test.describe("desktop chat outline", () => {
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
});
|
||||
|
||||
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
});
|
||||
|
||||
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
|
||||
test("keeps the current prompt active throughout a long live assistant turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -282,21 +279,11 @@ test.describe("desktop chat outline", () => {
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||
});
|
||||
|
||||
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 30);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
await test.step(
|
||||
"jump downward from old history to a newer loaded prompt",
|
||||
jumpToNewerLoadedPrompt.bind(null, page, agent),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -42,37 +41,93 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
test.describe("Command Center agent controls", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
|
||||
test("search, select, and re-rank live agent controls before navigating to an action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-live-controls-",
|
||||
title: "Command Center live controls",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
// A stale runtime mode makes selecting the provider's supported mode
|
||||
// exercise the live setting RPC instead of the selected-row no-op.
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
await openAgentRoute(page, workspace);
|
||||
await test.step("changes a running agent setting and preserves its selected row", async () => {
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await closeCommandCenter(page);
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
await test.step("matches query tokens in any order", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
await test.step("activates the best-matching row, not the highest-ranked group", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
await test.step("drops an arrow-key selection once the query changes", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await test.step("keeps an action first when the action is the best match", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -100,107 +155,6 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
// model. Asserting both model and mode catches either row winning.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-relevance-",
|
||||
title: "Command Center relevance",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("matches query tokens in any order", async ({ page }) => {
|
||||
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||
// a substring of it. Only per-token matching finds this row.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-tokenized-",
|
||||
title: "Command Center tokenized search",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-action-priority-",
|
||||
title: "Command Center action priority",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-active-reset-",
|
||||
title: "Command Center active reset",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -82,31 +82,20 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("dropping the files scope leaves the search row the same height", async () => {
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
});
|
||||
const reopenedPanel = page.getByTestId("command-center-panel");
|
||||
await expect(reopenedPanel).toBeVisible({ timeout: 30_000 });
|
||||
const header = reopenedPanel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,8 +45,7 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
|
||||
@@ -107,73 +107,122 @@ async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
test("manage the active workspace and scope its commands when leaving the route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-manage-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
await test.step("renames the active workspace while its sidebar row is unmounted", async () => {
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("copies the workspace path and confirms with a toast", async () => {
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("flips the pin entry to Unpin after pinning from the palette", async () => {
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await action(panel, "Unpin").click();
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toHaveCount(0);
|
||||
});
|
||||
await test.step("grouping commands persist across reload and switch back", async () => {
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
});
|
||||
await test.step("lists only the globally-handled toggle off a workspace route", async () => {
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -183,35 +232,6 @@ test.describe("Command center workspace management", () => {
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
@@ -44,6 +44,16 @@ test.describe("Command center workspaces", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await test.step("single-host results show project and branch without a host label", async () => {
|
||||
const singleHostPanel = await openCommandCenter(page);
|
||||
const singleHostRow = singleHostPanel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(singleHostRow.getByTestId("command-center-workspace-subtitle")).toHaveText(
|
||||
`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
@@ -121,38 +131,4 @@ test.describe("Command center workspaces", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-single-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// No secondary host: with a single host, the host label is gated away.
|
||||
await gotoAppShell(page);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
|
||||
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRemoteRepo(prefix: string): Promise<string> {
|
||||
const repo = await createTempGitRepo(prefix, {
|
||||
withRemote: true,
|
||||
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
|
||||
});
|
||||
cleanupTasks.push(repo.cleanup);
|
||||
// The helper puts the bare remote inside the checkout; keep it out of the status.
|
||||
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
|
||||
return repo.path;
|
||||
}
|
||||
|
||||
async function registerWorkspace(repoPath: string): Promise<string> {
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return created.workspace.id;
|
||||
}
|
||||
|
||||
async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId("changes-primary-cta-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
|
||||
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
|
||||
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", other]);
|
||||
});
|
||||
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
|
||||
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-push-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
|
||||
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
|
||||
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
@@ -71,14 +71,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await closeSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent's row shows the facts its sheet shows", async () => {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
@@ -205,21 +205,19 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
// Repeat after an explicit edit so retained focus from an earlier visit is
|
||||
// also dismissed when the same mounted editor becomes inactive again.
|
||||
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
|
||||
await composerLocator(page).fill(draft);
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await roundTripComposerTabBeforePaint(page, "Second touch chat");
|
||||
await expectComposerDraft(page, draft);
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, `${draft} after return`);
|
||||
}
|
||||
await composerLocator(page).fill("Keep this touch draft");
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expectComposerDraft(page, "");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerDraft(page, "Keep this touch draft");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, "Keep this touch draft after return");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -420,36 +420,38 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
test("context details preserve composer focus on hover and leave Find available after keyboard dismissal", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
test("Find remains available after dismissing context details", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
await test.step("pointer entry and dismissal preserve composer focus", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
await test.step("keyboard dismissal restores trigger focus and Find remains available", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
|
||||
interface DragMeasurement {
|
||||
observer: MutationObserver;
|
||||
stylesheetMutations: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForResizePaint(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function beginResizeMeasurement(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const stylesheet = document.getElementById("unistyles-web");
|
||||
if (!stylesheet) throw new Error("Shared stylesheet is missing");
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
|
||||
const measurement: DragMeasurement = {
|
||||
stylesheetMutations: 0,
|
||||
observer: new MutationObserver((records) => {
|
||||
measurement.stylesheetMutations += records.length;
|
||||
}),
|
||||
};
|
||||
measurement.observer.observe(stylesheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
|
||||
});
|
||||
}
|
||||
|
||||
async function previewExplorerResize(page: Page, distance: number) {
|
||||
const handle = page
|
||||
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||
.getByRole("separator");
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
const bounds = await handle.boundingBox();
|
||||
if (!bounds) throw new Error("Explorer resize handle is missing");
|
||||
const before = (await explorer.boundingBox())!.width;
|
||||
const x = bounds.x + bounds.width / 2;
|
||||
const y = bounds.y + 100;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await waitForResizePaint(page);
|
||||
await beginResizeMeasurement(page);
|
||||
for (let move = 1; move <= 20; move++) {
|
||||
await page.mouse.move(x + (distance * move) / 20, y);
|
||||
await waitForResizePaint(page);
|
||||
}
|
||||
const during = (await explorer.boundingBox())!.width;
|
||||
const measurement = await page.evaluate(() => {
|
||||
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
|
||||
state.observer.disconnect();
|
||||
return {
|
||||
stylesheetMutations: state.stylesheetMutations,
|
||||
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
|
||||
.filter(([id]) => id.startsWith("SplitPaneView:"))
|
||||
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
|
||||
};
|
||||
});
|
||||
await page.mouse.up();
|
||||
await waitForResizePaint(page);
|
||||
const after = (await explorer.boundingBox())!.width;
|
||||
expect(during).toBeCloseTo(before - distance, 0);
|
||||
expect(after).toBeCloseTo(during, 0);
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(explorer).toBeVisible();
|
||||
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
|
||||
}
|
||||
|
||||
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
const workspace = await withWorkspace();
|
||||
await workspace.navigateTo();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
|
||||
id.startsWith("SplitPaneView:"),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
const grown = await previewExplorerResize(page, -100);
|
||||
const shrunk = await previewExplorerResize(page, 50);
|
||||
expect(grown.stylesheetMutations).toBe(0);
|
||||
expect(grown.paneRenders).toBe(0);
|
||||
expect(shrunk.stylesheetMutations).toBe(0);
|
||||
expect(shrunk.paneRenders).toBe(0);
|
||||
await expectExplorerWidthSurvivesReload(page, shrunk.width);
|
||||
});
|
||||
@@ -105,25 +105,41 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
test("opens screenshot and non-ASCII Markdown 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)",
|
||||
"[Capture](.dev/tablet-review/capture.png) [报告](docs/reports/开户赠金.md) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await test.step("Open the linked screenshot", async () => {
|
||||
await openAssistantMarkdownLink(page, "Capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
|
||||
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
|
||||
});
|
||||
await test.step("Open the non-ASCII report named by its Markdown link", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
@@ -136,31 +152,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -690,7 +681,37 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await test.step("render sibling and parent-relative images in Markdown preview", async () => {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const guidePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(guidePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await guidePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
@@ -724,84 +745,30 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
test("isolates an HTML preview, blocks fetch egress, and refreshes while retaining source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
|
||||
const htmlPath = path.join(workspace.repoPath, "plan.html");
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "plan.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
`<!doctype html><html><head><title>probe</title></head><body>
|
||||
<h1>Visual plan</h1>
|
||||
<h1 id="script-result">Inline script did not run</h1>
|
||||
<p id="network-result">Network request not attempted</p>
|
||||
<p id="document-mode">Standards mode not detected</p>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.getElementById("script-result").textContent = "Inline script ran";
|
||||
if (document.compatMode === "CSS1Compat") {
|
||||
@@ -822,6 +789,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "probe.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Inline script ran" }),
|
||||
).toBeVisible();
|
||||
@@ -832,34 +801,26 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
preview.document.getByText("Network request blocked", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(blockedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "origin.html"),
|
||||
`<!doctype html><html><body>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "origin.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
await test.step("isolate the preview from app origin and storage", async () => {
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("refresh a changed HTML plan and switch to source and back", async () => {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
|
||||
@@ -6,20 +6,14 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
selectNewWorkspaceHost,
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const DRAFT = `Please investigate the workspace startup failure.
|
||||
@@ -29,70 +23,6 @@ Trace the request from the app through the daemon, preserve the existing behavio
|
||||
test.describe("New workspace composer draft", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps the draft when the project changes", async ({ page }) => {
|
||||
const firstProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-a-",
|
||||
});
|
||||
const secondProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-b-",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectKey,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectKey,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await firstProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the draft when the host changes", async ({ page }) => {
|
||||
const project: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-host-",
|
||||
});
|
||||
const secondaryServerId = "new-workspace-draft-secondary-host";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: secondaryServerId,
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
selectNewWorkspaceHost,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -90,6 +93,11 @@ test.describe("New workspace entry points", () => {
|
||||
projectLabel: seeded.projectDisplayName,
|
||||
hostLabel: "localhost",
|
||||
});
|
||||
await test.step("changing hosts preserves the draft", async () => {
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -121,6 +129,9 @@ test.describe("New workspace entry points", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
await test.step("Ctrl+P opens the project picker with search focused", async () => {
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -137,20 +148,6 @@ test.describe("New workspace entry points", () => {
|
||||
await expectNewWorkspaceControlsEnabled(page);
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -220,6 +217,8 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
|
||||
// Manually override the selection to C from inside A's screen. This stale
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
@@ -230,6 +229,9 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
await test.step("changing projects preserves the draft", async () => {
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
});
|
||||
|
||||
// Navigate via B's row icon. B must be preselected — the route project wins
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
|
||||
@@ -88,11 +88,23 @@ async function openNewWorkspaceFromContextProject(
|
||||
await expectNewWorkspaceProjectSelected(page, "Context project");
|
||||
}
|
||||
|
||||
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
|
||||
"new-workspace-project-preservation-secondary",
|
||||
);
|
||||
const test = base.extend<
|
||||
{ crossHostProject: CrossHostProjectScenario },
|
||||
{ secondaryDaemon: IsolatedHostDaemon }
|
||||
>({
|
||||
secondaryDaemon: [
|
||||
async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const daemon = await startIsolatedHostDaemon("new-workspace-project-preservation-secondary");
|
||||
try {
|
||||
await provide(daemon);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
crossHostProject: async ({ page: _page, secondaryDaemon }, provide) => {
|
||||
const contextRepo = await createTempGitRepo("new-workspace-context-");
|
||||
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
@@ -177,7 +189,6 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
await primaryDuplicateRepo.cleanup().catch(() => undefined);
|
||||
await secondaryFallbackRepo.cleanup().catch(() => undefined);
|
||||
await secondarySharedRepo.cleanup().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Regression for "the local / worktree selection in the new workspace is not
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
// Second visit (fresh mount of /new): the worktree choice must stick.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-composer-profile",
|
||||
name: "Composer Profile",
|
||||
command: "/bin/echo",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: the composer is one control in two modes", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
|
||||
profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectChatLaunchSelected,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
patchTerminalProfiles,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Wait for input until project cleanup kills the terminal. A fixed lifetime
|
||||
// can expire before a busy browser attaches or finishes revisiting the composer.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace: launch target memory", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const openNewWorkspace = () =>
|
||||
openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "remembered: remember me");
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
// The chat handoff navigates asynchronously (draft -> created agent). Let
|
||||
// it fully settle before moving on, so it can't fire a late redirect
|
||||
// that races the next step's navigation.
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
await patchTerminalProfiles([]);
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
@@ -15,6 +16,12 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
expectChatLaunchSelected,
|
||||
patchTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectTerminalPreviewCommand,
|
||||
@@ -67,21 +74,50 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
|
||||
test("switch launch modes, run prompt and bare profiles, and remember successful targets", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
|
||||
await test.step("open New workspace to choose a launch target", async () => {
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
});
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
await test.step("submit the prompt profile", async () => {
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "fix the flaky test");
|
||||
await submitTerminalLaunch(page);
|
||||
});
|
||||
@@ -96,6 +132,7 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
|
||||
@@ -107,6 +144,31 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
await test.step("successful launches remember their target across visits", async () => {
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectTerminalLaunchSelected(page, BARE_PROFILE.name);
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
await test.step("a removed remembered terminal profile falls back to Chat", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
try {
|
||||
await patchTerminalProfiles([]);
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
} finally {
|
||||
await patchTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||
@@ -157,6 +219,10 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,9 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
test("workspace button and shortcut follow the active project across visits", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -183,6 +185,15 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await test.step("global button follows project B", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -191,27 +202,15 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
await test.step("global button follows project A", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
expectWorkspaceIsolationSelected,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
@@ -723,9 +725,26 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
await test.step("cancel the branch picker without changing the selection", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
await test.step("select the filtered starting branch with the keyboard", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
@@ -749,6 +768,10 @@ test.describe("New workspace flow", () => {
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
await test.step("the worktree choice survives creating and reopening New workspace", async () => {
|
||||
await openNewWorkspaceComposer(page, openedProject);
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
@@ -890,57 +913,6 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>) {
|
||||
gate.holdNextServerMessage("status");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("status");
|
||||
gate.releaseHeldServerMessage("status");
|
||||
}
|
||||
|
||||
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace();
|
||||
await workspace.navigateTo();
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await createAgentTabFromMenu(page);
|
||||
await submitMessage(page, "hello");
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
|
||||
"Host is not connected",
|
||||
);
|
||||
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
|
||||
await restoreHostConnection(gate);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
|
||||
});
|
||||
@@ -226,10 +226,19 @@ test.describe("narrow touch browser", () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await test.step("reveal the first match below the floating Find widget", async () => {
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await test.step("tap the next uncovered match while controls stay inside the pane", async () => {
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
});
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
@@ -332,33 +341,6 @@ for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send message",
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -188,121 +188,3 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
const PIXEL_PNG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
test("Markdown images show a zoom-in cursor", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-image-cursor-",
|
||||
title: "Markdown image cursor",
|
||||
featureValues: { mockAssistantResponse: `Before.\n\n\n\nAfter.` },
|
||||
initialPrompt: "Show image",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
|
||||
await expect(image).toHaveCSS("cursor", "zoom-in");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "plugin-icons-e2e";
|
||||
const SVG_PATH = "M4 12l5 5L20 6";
|
||||
|
||||
const pluginClientSource = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
|
||||
|
||||
function BadgeIcon({ size, color }) {
|
||||
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
return <View><Text>Plugin icon panel</Text></View>;
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
|
||||
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
|
||||
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
|
||||
];
|
||||
|
||||
export default function contribute(client) {
|
||||
for (const panel of panels) {
|
||||
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
|
||||
client.addCommandCenterItem({
|
||||
id: panel.id,
|
||||
title: "Open " + panel.title,
|
||||
icon: panel.icon,
|
||||
context: "workspace",
|
||||
onSelect({ openPanel }) { openPanel(panel.id); },
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
function pluginTab(page: Page, panelId: string): Locator {
|
||||
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
|
||||
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test.describe("plugin icons", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
|
||||
await test.step("Command Center rows draw each icon form", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("icon panel");
|
||||
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
|
||||
await expect(
|
||||
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open SVG icon panel", exact: true })
|
||||
.locator(`path[d="${SVG_PATH}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open Component icon panel", exact: true })
|
||||
.getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "command-center-icons");
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("workspace panel tabs draw each icon form", async () => {
|
||||
await runCommand(page, "Open Lucide icon panel");
|
||||
await runCommand(page, "Open SVG icon panel");
|
||||
await runCommand(page, "Open Component icon panel");
|
||||
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
|
||||
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
|
||||
await expect(
|
||||
pluginTab(page, "component").getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "workspace-tab-icons");
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -749,7 +749,6 @@ function observePluginReload(page: Page) {
|
||||
return {
|
||||
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
|
||||
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
|
||||
requestedAt: () => requests[0]!,
|
||||
requestCount: () => requests.length,
|
||||
};
|
||||
}
|
||||
@@ -814,12 +813,6 @@ async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string
|
||||
});
|
||||
}
|
||||
|
||||
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
|
||||
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
|
||||
const remaining = 66_000 - (Date.now() - requestedAt);
|
||||
if (remaining > 0) await page.waitForTimeout(remaining);
|
||||
}
|
||||
|
||||
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
@@ -833,6 +826,9 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Advance the real browser client's RPC timers while the daemon's build gate
|
||||
// remains held on its own clock.
|
||||
await page.clock.install();
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
@@ -851,7 +847,7 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
await page.clock.fastForward(66_000);
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
id: "devin" | "hermes" | "kiro",
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
}, ACP_PROVIDER_ICON_SVGS[id]);
|
||||
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
|
||||
for (const [index, path] of expectedPaths.entries()) {
|
||||
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
|
||||
}
|
||||
|
||||
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openProviderCatalog(page);
|
||||
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openGroupedProjectWorkspace,
|
||||
expectWorkspaceHost,
|
||||
openProjectsForSettingsHost,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
@@ -420,29 +418,4 @@ test.describe("Sidebar project grouping", () => {
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
test("creates grouped project workspaces on the active workspace host", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: PRIMARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +316,9 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
test("opens a GitHub-backed workspace with folder metadata and pointer and keyboard hover cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
@@ -335,6 +337,46 @@ test.describe("Sidebar workspace list", () => {
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row).toContainText("main");
|
||||
|
||||
await test.step("inspect host metadata before opening the workspace", async () => {
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
});
|
||||
|
||||
await test.step("open the workspace and inspect its selected folder header", async () => {
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("dismiss a clicked row's hover card when the pointer leaves", async () => {
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("retain keyboard preview until Escape or focus leaves the row", async () => {
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -354,107 +396,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
||||
"main",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card shows host as metadata", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
@@ -495,26 +436,21 @@ test.describe("Sidebar workspace list", () => {
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("showMobileAgent open and close transition", async ({ page }) => {
|
||||
test("opens mobile navigation beside the close button and closes it again", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await test.step("place the close button beside the first navigation row", async () => {
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -587,15 +523,14 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("start with pinned navigation at half of a 14-inch Mac display", async () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
|
||||
@@ -266,22 +266,14 @@ test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
|
||||
gate.release(1);
|
||||
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -589,69 +588,6 @@ async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const compact of [false, true]) {
|
||||
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
|
||||
});
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active quota" },
|
||||
});
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "../support/fixtures";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
@@ -27,31 +28,46 @@ interface ViewedTimelineScenario {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
type ViewedAgentConfig = Pick<
|
||||
Parameters<SeedDaemonClient["createAgent"]>[0],
|
||||
"provider" | "modeId" | "model"
|
||||
>;
|
||||
|
||||
async function seedViewedTimelineScenario(
|
||||
options: { firstAgentModel?: string } = {},
|
||||
options: { firstAgent?: ViewedAgentConfig } = {},
|
||||
): Promise<ViewedTimelineScenario> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
||||
const createAgent = (title: string, model = "ten-second-stream") =>
|
||||
workspace.client.createAgent({
|
||||
const createAgent = (
|
||||
title: string,
|
||||
config: ViewedAgentConfig = {
|
||||
provider: "mock",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
},
|
||||
) =>
|
||||
workspace.client.createAgent({
|
||||
...config,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model,
|
||||
});
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgentModel),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
try {
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgent),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface RestoredLayoutChat {
|
||||
@@ -306,19 +322,39 @@ test.describe("Viewed agent timelines", () => {
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||
const stream = await createControlledStream();
|
||||
const scenario = await seedViewedTimelineScenario({
|
||||
firstAgent: { provider: stream.provider },
|
||||
}).catch(async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
|
||||
await stream.append(["The first chat is still producing output."]);
|
||||
await expect(
|
||||
page.getByText("The first chat is still producing output.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||
const agents = await scenario.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find((entry) => entry.agent.id === scenario.firstAgentId)?.agent.status,
|
||||
).toBe("running");
|
||||
await stream.finish([" Completed while hidden."]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expectAgentConsistentlyIdle(page, "First viewed chat");
|
||||
await expect(page.getByText("Completed while hidden.", { exact: false })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
closeWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
observeSetupPanelTraffic,
|
||||
} from "../support/helpers/workspace-setup";
|
||||
|
||||
interface WorkspaceScriptStarter {
|
||||
@@ -117,45 +116,6 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-membership-", {
|
||||
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
|
||||
});
|
||||
const traffic = observeSetupPanelTraffic(page);
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "setup-membership",
|
||||
});
|
||||
await completed;
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await traffic.expectNoDemand();
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 1);
|
||||
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await traffic.expectReleased(0);
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 2);
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await traffic.expectReleased(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package sh.paseo.diagnosis;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.common.mapbuffer.WritableMapBuffer;
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping;
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder;
|
||||
import com.facebook.react.uimanager.PixelUtil;
|
||||
import com.facebook.react.uimanager.ThemedReactContext;
|
||||
import com.facebook.react.views.text.ReactTextUpdate;
|
||||
import com.facebook.react.views.text.ReactTextView;
|
||||
import com.facebook.react.views.text.TextLayoutManager;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
import com.facebook.yoga.YogaMeasureMode;
|
||||
import com.facebook.yoga.YogaMeasureOutput;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/** Native reproduction for #4368. Uses the measurement and rendering paths used by RN Text. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class AboutFontWeightTest {
|
||||
@Test public void ordinaryVersionFitsItsMeasuredBox() { checkVersion(0); }
|
||||
@Test public void boldVersionFitsItsMeasuredBox() { checkVersion(300); }
|
||||
|
||||
private void checkVersion(int adjustment) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||
Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
Configuration configuration = new Configuration(app.getResources().getConfiguration());
|
||||
configuration.fontWeightAdjustment = adjustment;
|
||||
Context configured = app.createConfigurationContext(configuration);
|
||||
try {
|
||||
SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
|
||||
} catch (java.io.IOException error) {
|
||||
throw new AssertionError("Could not initialize React Native", error);
|
||||
}
|
||||
DisplayMetricsHolder.initDisplayMetrics(configured);
|
||||
ThemedReactContext context = new ThemedReactContext(new BridgeReactContext(configured), configured);
|
||||
|
||||
WritableMapBuffer attributes = new WritableMapBuffer();
|
||||
attributes.put(4, 15.0); // TextAttributeProps.TA_KEY_FONT_SIZE
|
||||
attributes.put(9, true); // TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING
|
||||
WritableMapBuffer fragment = new WritableMapBuffer();
|
||||
fragment.put(0, "v0.12.0");
|
||||
fragment.put(1, 1);
|
||||
fragment.put(5, attributes);
|
||||
WritableMapBuffer fragments = new WritableMapBuffer();
|
||||
fragments.put(0, fragment);
|
||||
WritableMapBuffer text = new WritableMapBuffer();
|
||||
text.put(1, "v0.12.0");
|
||||
text.put(2, fragments);
|
||||
text.put(4, attributes);
|
||||
WritableMapBuffer paragraph = new WritableMapBuffer();
|
||||
paragraph.put(2, "highQuality");
|
||||
paragraph.put(5, "none");
|
||||
|
||||
long measured = TextLayoutManager.measureText(context, text, paragraph,
|
||||
1000, YogaMeasureMode.AT_MOST, 1000, YogaMeasureMode.AT_MOST, null, null);
|
||||
int width = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getWidth(measured)));
|
||||
int height = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getHeight(measured)));
|
||||
Spannable content = TextLayoutManager.INSTANCE.getOrCreateSpannableForText(context, text, null);
|
||||
ReactTextView view = new ReactTextView(context);
|
||||
view.setId(2); // Fabric tag, matching the app's new-architecture Text path.
|
||||
view.setText(new ReactTextUpdate(content, -1, false, 0,
|
||||
Layout.BREAK_STRATEGY_HIGH_QUALITY, Layout.JUSTIFICATION_MODE_NONE));
|
||||
view.measure(exact(width), exact(height));
|
||||
view.layout(0, 0, width, height);
|
||||
Layout rendered = view.getLayout();
|
||||
Log.i("AboutFontWeightTest", "adjustment=" + adjustment + " box=" + width + "x" + height
|
||||
+ " lines=" + rendered.getLineCount() + " lineWidth=" + rendered.getLineWidth(0)
|
||||
+ " weight=" + view.getTypeface().getWeight());
|
||||
assertEquals("The complete version should fit on its measured line", 1, rendered.getLineCount());
|
||||
assertTrue("The last digit must remain inside the allocated width",
|
||||
rendered.getLineWidth(0) <= width);
|
||||
assertTrue("The complete line must remain inside the allocated height",
|
||||
rendered.getLineBottom(0) <= height);
|
||||
});
|
||||
}
|
||||
|
||||
private static int exact(int size) {
|
||||
return View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# About version clipping reproduction ([#4368](https://github.com/getpaseo/paseo/issues/4368))
|
||||
|
||||
This is a diagnostic branch, with no production fix. The Android test compares
|
||||
React Native's measured box for `v0.12.0` with the native TextView layout that
|
||||
renders the same attributed text. It runs both ordinary and system-bold text.
|
||||
It does not turn off accessibility scaling or bold text.
|
||||
|
||||
## Run the native test
|
||||
|
||||
From the repository root, prepare a development Android project if necessary:
|
||||
|
||||
```sh
|
||||
cd packages/app
|
||||
APP_VARIANT=development npx expo prebuild --platform android --no-install
|
||||
```
|
||||
|
||||
In generated `android/app/build.gradle`, add:
|
||||
|
||||
```groovy
|
||||
android.defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
dependencies {
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
}
|
||||
```
|
||||
|
||||
Copy this test into the generated test source set, then build the app and tests:
|
||||
|
||||
```sh
|
||||
mkdir -p android/app/src/androidTest/java/sh/paseo/diagnosis
|
||||
cp e2e/mobile/about-font-size/AboutFontWeightTest.java android/app/src/androidTest/java/sh/paseo/diagnosis/
|
||||
cd android
|
||||
APP_VARIANT=development ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest \
|
||||
-PreactNativeArchitectures=x86_64 --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
adb shell am instrument -w -e class sh.paseo.diagnosis.AboutFontWeightTest \
|
||||
sh.paseo.debug.test/androidx.test.runner.AndroidJUnitRunner
|
||||
```
|
||||
|
||||
The test requires Android 12 or later for `fontWeightAdjustment`. The native
|
||||
contexts set that adjustment to 0 and 300 independently; they do not change the
|
||||
device setting. The selected device's density and font scale still apply.
|
||||
|
||||
On Android 16 at 540 dpi and 2× font scale, both contexts receive a 301×123-pixel
|
||||
box. The ordinary-weight control passes, with one rendered line. The bold test
|
||||
fails with two rendered lines, so the final digit falls outside the allocated
|
||||
single-line height. This isolates the measurement/rendering disagreement from
|
||||
the About row's flex layout. A native dependency/build decision is still needed
|
||||
before implementing a fix; changing an About-row style would leave the producer
|
||||
of the incorrect measurement in place.
|
||||
|
||||
## UI comparison
|
||||
|
||||
Observed on Android 16, with current main's JavaScript and a freshly built native
|
||||
development app: open Settings → About with 2× system font scale and 540 dpi on
|
||||
a 1080-pixel-wide emulator. With system bold text, the displayed app version
|
||||
loses its final digit (`v0.12.`). At the same display and text sizes without
|
||||
system bold, the complete `v0.12.0` is visible. Cold-launch after configuration
|
||||
changes before comparing; changing font scale in a running app also produced
|
||||
temporary vertical clipping, which disappeared after a cold launch.
|
||||
|
||||
This substitutes standard Android accessibility settings for Samsung Easy Mode.
|
||||
It verifies the truncated-version symptom, not Samsung's complete UI or the
|
||||
already-fixed home-screen overlap. The reporter used Paseo 0.7.2; this branch
|
||||
uses main `dd244c076d5f17d56d5592c7fb173dc7fdc2bbae`.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Prerequisite: a native terminal showing the output of echo '한글 문장'
|
||||
# on its first row, with no active selection. Use a dedicated QA workspace.
|
||||
# Coordinates target the first half of 문 on a 1080x1920 phone at default
|
||||
# code size. Override WORD_X/WORD_Y for other device geometry.
|
||||
context platform=android timeout=30000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env WORD_X=105
|
||||
env WORD_Y=251
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"Terminal input\"" 10000
|
||||
wait "label=\"한글 문장\"" 10000
|
||||
longpress "${WORD_X}" "${WORD_Y}" 650
|
||||
wait "id=\"terminal-copy\"" 5000
|
||||
press "id=\"terminal-copy\""
|
||||
# Copy clears the selection; only Android's clipboard preview has this exact
|
||||
# text, since the unselected terminal row contains the full sentence.
|
||||
wait "text=\"문장\"" 3000
|
||||
close
|
||||
@@ -1,145 +0,0 @@
|
||||
// Run against an Android dev client serving this checkout, with a real software keyboard.
|
||||
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
|
||||
assert(
|
||||
session && metroUrl && serverId,
|
||||
"Pass the agent-device session, Metro URL and connected host ID",
|
||||
);
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
|
||||
}
|
||||
|
||||
// Read the mounted app's public React Native APIs through Metro. Accessibility
|
||||
// snapshots with Gboard open can block while waiting for an idle hierarchy.
|
||||
const reactNative = `Array.from(__r.getModules().values()).find(
|
||||
m => m.verboseName?.endsWith("/react-native/index.js")
|
||||
).publicModule.exports`;
|
||||
|
||||
function measureFocusedInput() {
|
||||
evaluate(`(() => {
|
||||
const rn = ${reactNative};
|
||||
globalThis.__appearanceKeyboardMeasurement = null;
|
||||
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
|
||||
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
|
||||
x, y, width, height,
|
||||
statusBarHeight: rn.StatusBar.currentHeight,
|
||||
scale: rn.PixelRatio.get(),
|
||||
keyboard: rn.Keyboard.metrics(),
|
||||
});
|
||||
});
|
||||
})()`);
|
||||
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async function waitForFocusedInput() {
|
||||
let measurement;
|
||||
let previous;
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
measurement = measureFocusedInput();
|
||||
const current = measurement?.keyboard
|
||||
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
|
||||
: null;
|
||||
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
|
||||
previous = current;
|
||||
if (stable >= 3) break;
|
||||
await delay(150);
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
ad("open", appId, "--platform", "android", "--no-test-ime");
|
||||
const targets = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(appId));
|
||||
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
|
||||
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
|
||||
|
||||
try {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Appearance", "5000");
|
||||
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Code size", "5000");
|
||||
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const numericFields = snapshot.data.nodes.filter(
|
||||
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
|
||||
);
|
||||
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
|
||||
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
|
||||
// Appearance orders Interface size, Content size, then Code size.
|
||||
ad("press", `@${numericFields[2].ref}`);
|
||||
|
||||
const measurement = await waitForFocusedInput();
|
||||
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
|
||||
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
|
||||
const keyboardTop = measurement.keyboard.screenY;
|
||||
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
|
||||
ad("screenshot", resolve(artifacts, "keyboard.png"));
|
||||
assert(
|
||||
inputBottom <= keyboardTop + 1 / measurement.scale,
|
||||
"Code size must be fully visible above the number keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
|
||||
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
|
||||
"--platform",
|
||||
"android",
|
||||
"--no-test-ime",
|
||||
);
|
||||
ad("wait", "text", "Plugin source", "5000");
|
||||
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
|
||||
assert(source, "The connected host must support installing plugins from source");
|
||||
ad("press", `@${source.ref}`);
|
||||
ad("type", "qa-source");
|
||||
const pluginMeasurement = await waitForFocusedInput();
|
||||
// A FormTextInput draws its border and padding on the native input's parent.
|
||||
// Measure that visible frame, rather than accepting only the text as visible.
|
||||
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
|
||||
assert(focused, "Plugin source must remain focused after typing");
|
||||
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
|
||||
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
|
||||
assert(frame, "The source input's visible frame must be present in the native hierarchy");
|
||||
assert(
|
||||
frame.rect.height > focused.rect.height,
|
||||
"Measure the surrounding frame, including padding",
|
||||
);
|
||||
const frameBottom = frame.rect.y + frame.rect.height;
|
||||
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
|
||||
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
|
||||
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
|
||||
assert(
|
||||
frameBottom <= pluginKeyboardTop + 1,
|
||||
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
|
||||
} finally {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Late composer pill clearance
|
||||
|
||||
This regression opens a completed conversation, relaunches the Android app, and
|
||||
registers a composer pill five seconds after the client plugin loads. It checks
|
||||
that the completed turn's Copy and Fork buttons remain fully above the pill.
|
||||
The daemon, plugin API, native app, layout, and accessibility bounds are real;
|
||||
no provider calls are needed during the test.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Build the server and app dependencies with `npm run build:server` and
|
||||
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
|
||||
- Run an isolated development daemon on a non-default port.
|
||||
- Run this checkout's Metro server and open the connected Android dev client
|
||||
with `agent-device`. The app must already know the isolated daemon.
|
||||
- Open a completed conversation on a compact portrait Android emulator, at the
|
||||
latest turn, with no other composer tracks visible. Keep the software keyboard
|
||||
closed. Note the workspace and agent IDs from the daemon.
|
||||
|
||||
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
|
||||
repository root, substituting the session and ports:
|
||||
|
||||
```sh
|
||||
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
|
||||
node packages/app/e2e/mobile/composer-overlay/android.mjs \
|
||||
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
|
||||
```
|
||||
|
||||
The runner installs and removes its delayed plugin and leaves the conversation
|
||||
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
|
||||
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
|
||||
explicit directory, temporary evidence is removed with the fixture.
|
||||
|
||||
To check an existing capture independently:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
|
||||
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
|
||||
```
|
||||
|
||||
On the failing baseline, the Copy button extends below the pill's top edge. With
|
||||
the correction, both actions are clear before the composer receives focus.
|
||||
|
||||
Additional checks on the same conversation: opening the keyboard keeps the footer
|
||||
clear; after scrolling up, a newly registered pill must not jump back to the latest
|
||||
turn. Native iOS uses a different keyboard implementation and needs its own device
|
||||
verification.
|
||||
@@ -1,95 +0,0 @@
|
||||
// Start on a completed conversation in the connected Android dev client.
|
||||
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
|
||||
|
||||
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
|
||||
process.argv;
|
||||
assert(
|
||||
session && daemonUrl && workspaceId && agentId,
|
||||
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
|
||||
);
|
||||
const url = new URL(daemonUrl);
|
||||
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
|
||||
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
|
||||
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const pluginId = "composer-overlay-mobile-repro";
|
||||
const pillLabel = "Overlay regression pill";
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
clientId: "composer-overlay-mobile-regression",
|
||||
clientType: "cli",
|
||||
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
|
||||
webSocketFactory: (address, options) => new WebSocket(address, options),
|
||||
});
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
let installed = false;
|
||||
let previousConfig;
|
||||
try {
|
||||
ad("wait", 'label="Copy turn"', "20000");
|
||||
await client.connect();
|
||||
previousConfig = (await client.getDaemonConfig()).config;
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
requirements: { paseo: ">=0.11.4" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
let pill;
|
||||
const timer = setTimeout(() => {
|
||||
pill = client.addComposerPill({
|
||||
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
|
||||
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
|
||||
});
|
||||
}, 5000);
|
||||
return () => { clearTimeout(timer); pill?.remove(); };
|
||||
}`,
|
||||
);
|
||||
await client.installDirectoryPlugin(directory);
|
||||
installed = true;
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
// Match the report: reopen a completed conversation, then let the plugin register late.
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
"--platform",
|
||||
"android",
|
||||
"--metro-port",
|
||||
metroPort,
|
||||
"--no-test-ime",
|
||||
"--relaunch",
|
||||
);
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
const capture = path.join(artifacts, "cold-reopen.json");
|
||||
await writeFile(capture, ad("snapshot", "--raw", "--json"));
|
||||
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} finally {
|
||||
if (installed) await client.removePlugin(pluginId);
|
||||
if (previousConfig)
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
|
||||
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , snapshotPath, pillLabel] = process.argv;
|
||||
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
|
||||
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
||||
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
|
||||
const pill = buttons.find((node) => node.label === pillLabel);
|
||||
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
|
||||
for (const label of ["Copy turn", "Fork chat from here"]) {
|
||||
const button = buttons.find((node) => node.label === label);
|
||||
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(
|
||||
bottom <= pill.rect.y,
|
||||
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
|
||||
);
|
||||
}
|
||||
console.log("PASS: completed-turn actions remain fully above the composer pill");
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
/** A single-turn producer controlled through the daemon, before timeline persistence. */
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
let startTurn!: (append: (chunks: string[], finish: boolean) => void) => void;
|
||||
const turn = new Promise<(chunks: string[], finish: boolean) => void>((resolve) => {
|
||||
startTurn = resolve;
|
||||
});
|
||||
server.handle(
|
||||
defineRpc({
|
||||
name: "append",
|
||||
input: z.object({ chunks: z.array(z.string()), finish: z.boolean() }),
|
||||
output: z.null(),
|
||||
}),
|
||||
async ({ chunks, finish }) => {
|
||||
const append = await turn;
|
||||
append(chunks, finish);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
server.registerProvider(
|
||||
runAcpProvider({ id: "controlled-stream", label: "Controlled stream", connector: connect }),
|
||||
);
|
||||
return () => {};
|
||||
|
||||
function connect() {
|
||||
let controller: ReadableStreamDefaultController<AcpStreamMessage>;
|
||||
return {
|
||||
readable: new ReadableStream<AcpStreamMessage>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream<AcpStreamMessage>({
|
||||
write(message) {
|
||||
if (!("method" in message) || !("id" in message)) return;
|
||||
if (message.method === "session/prompt") {
|
||||
let finished = false;
|
||||
startTurn((chunks, finish) => {
|
||||
if (finished) return;
|
||||
for (const text of chunks) {
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "controlled",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (finish) {
|
||||
finished = true;
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { stopReason: "end_turn" },
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let result: unknown = {};
|
||||
if (message.method === "initialize") {
|
||||
result = { protocolVersion: 1, agentCapabilities: {} };
|
||||
} else if (message.method === "session/new") {
|
||||
result = { sessionId: "controlled" };
|
||||
}
|
||||
controller.enqueue({ jsonrpc: "2.0", id: message.id, result });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } }
|
||||
@@ -203,8 +203,7 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(lineBox).not.toBeNull();
|
||||
expect(filenameBox).not.toBeNull();
|
||||
expect(pathBox).not.toBeNull();
|
||||
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
|
||||
expect(rowBox?.height).toBe(32);
|
||||
expect(rowBox?.height).toBe(36);
|
||||
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
|
||||
@@ -501,59 +501,3 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
}
|
||||
|
||||
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
|
||||
export async function roundTripComposerTabBeforePaint(
|
||||
page: Page,
|
||||
activeChatTitle: string,
|
||||
): Promise<void> {
|
||||
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
|
||||
await expect(activeTab).toHaveAttribute("aria-selected", "true");
|
||||
const result = await activeTab.evaluate((tab) => {
|
||||
const editor = document.activeElement;
|
||||
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
|
||||
let switchedAway = false;
|
||||
let painted = false;
|
||||
let frame = 0;
|
||||
const shortcut = (code: "BracketLeft" | "BracketRight") => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: code === "BracketLeft" ? "{" : "}",
|
||||
code,
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
// Watch selection commits without reading layout: a layout read could make
|
||||
// Chromium blur the hidden editor and erase the condition under test.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
|
||||
switchedAway = true;
|
||||
frame = requestAnimationFrame(() => {
|
||||
painted = true;
|
||||
});
|
||||
// Deliver the return shortcut before rendering, without a Playwright
|
||||
// round trip allowing the browser to paint the inactive tab first.
|
||||
shortcut("BracketRight");
|
||||
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
return resolve({
|
||||
returnedBeforePaint: !painted,
|
||||
focused: document.activeElement === editor,
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
expect(
|
||||
result.returnedBeforePaint,
|
||||
"tab round trip must finish before rendering the hidden pane",
|
||||
).toBe(true);
|
||||
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
|
||||
type ControlledStreamClient = Pick<
|
||||
DaemonClient,
|
||||
| "connect"
|
||||
| "close"
|
||||
| "getDaemonConfig"
|
||||
| "patchDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "removePlugin"
|
||||
| "invokePluginRpc"
|
||||
>;
|
||||
|
||||
/** Owns one real provider turn whose completion the test explicitly releases. */
|
||||
export async function createControlledStream() {
|
||||
const client = await connectDaemonClient<ControlledStreamClient>({
|
||||
clientIdPrefix: "controlled-stream",
|
||||
});
|
||||
const previous = await client.getDaemonConfig();
|
||||
let appended = false;
|
||||
const emit = (chunks: string[], finish: boolean) =>
|
||||
client.invokePluginRpc("controlled-stream-test", "append", { chunks, finish });
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
if (appended) await emit([], true);
|
||||
await client.removePlugin("controlled-stream-test");
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../fixtures/controlled-stream-plugin"),
|
||||
);
|
||||
return {
|
||||
provider: "controlled-stream",
|
||||
append: async (chunks: string[]) => {
|
||||
await emit(chunks, false);
|
||||
appended = true;
|
||||
},
|
||||
finish: async (chunks: string[] = []) => {
|
||||
await emit(chunks, true);
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ interface E2EDaemonClientConfig {
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(port?: number): string {
|
||||
@@ -35,7 +34,6 @@ export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
port?: number;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +51,6 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
capabilities: options.capabilities,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
|
||||
@@ -134,19 +134,3 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGroupedProjectWorkspace(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
await projectGroup(page, projectName)
|
||||
.getByRole("button")
|
||||
.filter({ hasText: workspaceName })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + height - row.height;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
@@ -12,27 +13,6 @@ export const scrollCadences = [
|
||||
] as const;
|
||||
|
||||
type Cadence = (typeof scrollCadences)[number];
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
interface Recording {
|
||||
frames: ScrollFrame[];
|
||||
finishInput(): void;
|
||||
@@ -404,80 +384,6 @@ export async function recordUpwardTraversal(
|
||||
});
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + height - row.height;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportScrollJumps(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
|
||||
@@ -135,14 +135,3 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
if (compact) {
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(text);
|
||||
return;
|
||||
}
|
||||
await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text);
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ export type { WorkspaceSetupDaemonClient };
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
|
||||
clientIdPrefix: "workspace-setup",
|
||||
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
|
||||
capabilities: { workspace_setup_observation: false },
|
||||
});
|
||||
// Establish demand before a caller can launch setup. Client close releases it.
|
||||
try {
|
||||
@@ -328,51 +326,3 @@ export async function waitForWorkspaceSetupProgress(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Observe the browser's actual setup membership without requesting any output. */
|
||||
export function observeSetupPanelTraffic(page: Page) {
|
||||
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
|
||||
const releases = new Set<string>();
|
||||
const requests: string[] = [];
|
||||
let legacySetupDemand = false;
|
||||
const outputWorkspaces: string[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const frame = JSON.parse(String(payload));
|
||||
if (frame.type !== "session") return;
|
||||
if (frame.message?.type === "subscription.release.request")
|
||||
releases.add(frame.message.subscriptionId);
|
||||
if (frame.message?.type === "workspace.setup.subscribe.request")
|
||||
requests.push(frame.message.workspaceId);
|
||||
if (
|
||||
frame.message?.type === "session.events.set_subscription.request" &&
|
||||
frame.message.events.includes("workspace_setup_progress")
|
||||
)
|
||||
legacySetupDemand = true;
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const frame = JSON.parse(String(payload));
|
||||
if (frame.type !== "session") return;
|
||||
if (frame.message?.type === "workspace.setup.subscribe.response")
|
||||
subscriptions.push(frame.message.payload);
|
||||
if (frame.message?.type === "workspace_setup_progress")
|
||||
outputWorkspaces.push(frame.message.payload.workspaceId);
|
||||
});
|
||||
});
|
||||
return {
|
||||
async expectNoDemand() {
|
||||
expect(requests).toEqual([]);
|
||||
expect(legacySetupDemand).toBe(false);
|
||||
expect(subscriptions).toEqual([]);
|
||||
expect(outputWorkspaces).toEqual([]);
|
||||
},
|
||||
async expectSubscribed(workspaceId: string, count: number) {
|
||||
await expect.poll(() => subscriptions.length).toBe(count);
|
||||
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
|
||||
expect(requests).toEqual(Array(count).fill(workspaceId));
|
||||
},
|
||||
async expectReleased(index: number) {
|
||||
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -118,8 +118,8 @@ export function ChatFind({
|
||||
}, [active, isInteractive, model]);
|
||||
const close = useCallback(() => {
|
||||
model.close();
|
||||
viewportRef.current?.focus?.();
|
||||
}, [model, viewportRef]);
|
||||
root.current?.focus({ preventScroll: true });
|
||||
}, [model]);
|
||||
let status = "";
|
||||
if (state.phase === "searching") status = t("paneFind.searching");
|
||||
else if (state.phase === "loading") status = t("paneFind.loading");
|
||||
|
||||
@@ -76,49 +76,3 @@ 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,55 +21,28 @@ export function findMessageMatches(
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
function renderedTextGroups(row: HTMLElement): Text[][] {
|
||||
const groups: Text[][] = [];
|
||||
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
|
||||
// whole row is searched and each div (a React Native View or Text) bounds a match.
|
||||
const pluginRow = row.dataset.pluginRow === "true";
|
||||
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
|
||||
const contents = pluginRow
|
||||
? [row]
|
||||
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
|
||||
for (const content of contents) {
|
||||
let group: { block: Element; texts: Text[] } | undefined;
|
||||
const walker = document.createTreeWalker(
|
||||
content,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node instanceof Element) {
|
||||
// Even an empty nested block separates the text before and after it.
|
||||
if (node.matches(blocks)) group = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
if (
|
||||
pluginRow &&
|
||||
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
)
|
||||
continue;
|
||||
const block = node.parentElement?.closest(blocks) ?? content;
|
||||
if (group?.block !== block) {
|
||||
group = { block, texts: [] };
|
||||
groups.push(group.texts);
|
||||
}
|
||||
group.texts.push(node);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
const groups = new Map<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
const block = node.parentElement?.closest(BLOCKS) ?? content;
|
||||
const texts = groups.get(block) ?? [];
|
||||
texts.push(node);
|
||||
groups.set(block, texts);
|
||||
}
|
||||
}
|
||||
const pattern = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
const ranges: Range[] = [];
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
for (const texts of groups.values()) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
|
||||
describe("native scroll-to-bottom animation", () => {
|
||||
it("jumps across unmounted history instead of animating through blank rows", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps near-bottom movement eligible for animation", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds travel by the bottom-side overscan, not the whole window", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not animate before the viewport has been measured", () => {
|
||||
expect(
|
||||
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export function shouldAnimateNativeScrollToBottom(input: {
|
||||
offsetY: number;
|
||||
viewportHeight: number;
|
||||
windowSize: number;
|
||||
}): boolean {
|
||||
// VirtualizedList splits the extra window around the viewport. An animated
|
||||
// jump farther than the bottom-side overscan crosses unmounted history.
|
||||
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
|
||||
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardStreamInset } from "@/keyboard/shift";
|
||||
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -65,7 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -240,13 +238,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -599,11 +591,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
// A history row can contain an entire long reply. Keep synchronous native
|
||||
// Markdown measurement bounded while FlatList fills the surrounding window.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -1162,7 +1162,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
|
||||
scrollToBottom: () => {
|
||||
setFollowOutput(true);
|
||||
cancelPendingStickToBottom();
|
||||
@@ -1251,7 +1250,6 @@ 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
|
||||
@@ -1269,7 +1267,6 @@ 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
|
||||
@@ -1332,7 +1329,6 @@ 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,8 +51,6 @@ export interface ScrollToMessageOccurrence {
|
||||
}
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
|
||||
@@ -771,7 +771,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
serverId={resolvedServerId}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -796,7 +795,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
// Read through a stable event so live group updates do not change the renderer identity
|
||||
@@ -1252,12 +1251,6 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) {
|
||||
reasons.push("bottomOverlayControlClearance");
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
|
||||
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
|
||||
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 975 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 19 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>
|
||||
|
Before Width: | Height: | Size: 895 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 334 B |
@@ -16,10 +16,7 @@ import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
@@ -32,11 +29,8 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { createProjectIconTarget } from "@/projects/icon-target";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
@@ -55,14 +49,8 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import {
|
||||
COMMAND_CENTER_LINE_HEIGHT,
|
||||
COMMAND_CENTER_LIST_METRICS,
|
||||
COMMAND_CENTER_SECTION_GAP,
|
||||
type CommandCenterListMetrics,
|
||||
} from "./list-metrics";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import {
|
||||
@@ -101,14 +89,42 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||
const leftRunning = left.status === "running" ? 1 : 0;
|
||||
const rightRunning = right.status === "running" ? 1 : 0;
|
||||
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function compareWorkspacesByTitle(
|
||||
left: CommandCenterWorkspaceResult,
|
||||
right: CommandCenterWorkspaceResult,
|
||||
): number {
|
||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
||||
}
|
||||
|
||||
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
|
||||
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
|
||||
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
|
||||
* collation sort over every workspace.
|
||||
*
|
||||
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
|
||||
* now refresh when agents or projects change rather than on every keystroke.
|
||||
*/
|
||||
function useBuiltInRows(open: boolean): {
|
||||
workspaces: CommandCenterWorkspaceResult[];
|
||||
@@ -118,32 +134,11 @@ function useBuiltInRows(open: boolean): {
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
open
|
||||
? projects.flatMap((project) =>
|
||||
project.hosts.flatMap((host) => {
|
||||
const target = createProjectIconTarget({
|
||||
projectViewKey: project.viewKey,
|
||||
placement: { ...host, iconWorkingDir: host.repoRoot },
|
||||
});
|
||||
return target ? [target] : [];
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
[open, projects],
|
||||
);
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return { workspaces: [], agents: [] };
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
viewKey: project.viewKey,
|
||||
name: project.projectName,
|
||||
iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null,
|
||||
};
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
@@ -156,9 +151,7 @@ function useBuiltInRows(open: boolean): {
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
@@ -167,31 +160,34 @@ function useBuiltInRows(open: boolean): {
|
||||
}
|
||||
}
|
||||
}
|
||||
allWorkspaces.sort(compareWorkspacesByTitle);
|
||||
const workspaceTitleByKey = new Map(
|
||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||
);
|
||||
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
});
|
||||
const agentRows = agents
|
||||
.map<CommandCenterAgentResult>((agent) => {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
formatTimeAgo(agent.lastActivityAt),
|
||||
]),
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
}, [agents, open, projects, showHost, t]);
|
||||
}
|
||||
|
||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||
@@ -206,14 +202,16 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 3,
|
||||
title: t("shell.commandCenter.agents"),
|
||||
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
|
||||
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
|
||||
sortAgents(left.agent, right.agent),
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [open, query, rows, t]);
|
||||
@@ -238,7 +236,7 @@ interface CommandCenterState {
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
|
||||
function useCommandCenterState(): CommandCenterState {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
@@ -292,9 +290,8 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
scope === "files"
|
||||
? fileSections
|
||||
: [...contributionSections, ...fileSections, ...builtInSections],
|
||||
listMetrics,
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
[builtInSections, contributionSections, fileSections, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
@@ -384,19 +381,11 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
|
||||
interface ResultRowProps {
|
||||
result: CommandCenterResult;
|
||||
height: number;
|
||||
query: string;
|
||||
active: boolean;
|
||||
onSelect(result: CommandCenterResult): void;
|
||||
}
|
||||
|
||||
const ResultRow = memo(function ResultRow({
|
||||
result,
|
||||
height,
|
||||
query,
|
||||
active,
|
||||
onSelect,
|
||||
}: ResultRowProps) {
|
||||
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
@@ -413,10 +402,15 @@ const ResultRow = memo(function ResultRow({
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
{ height },
|
||||
(result.kind === "agent" ||
|
||||
result.kind === "workspace" ||
|
||||
(result.kind === "contribution" &&
|
||||
result.contribution.presentation.kind === "action" &&
|
||||
Boolean(result.contribution.presentation.subtitle))) &&
|
||||
styles.tallRow,
|
||||
(Boolean(hovered) || pressed || active) && styles.activeRow,
|
||||
],
|
||||
[active, height],
|
||||
[active, result],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
@@ -430,75 +424,12 @@ const ResultRow = memo(function ResultRow({
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} query={query} />
|
||||
<ResultContent result={result} />
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Text with the query's terms marked. Marks use the same matcher options the rows were ranked
|
||||
* with, so a mark always points at something that made the row match.
|
||||
*/
|
||||
function QueryHighlightedText({
|
||||
query,
|
||||
text,
|
||||
...props
|
||||
}: { query: string } & Omit<HighlightedTextProps, "ranges">) {
|
||||
const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]);
|
||||
return <HighlightedText text={text} ranges={ranges} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch
|
||||
* truncates without pushing the time out of view, and so the time is never part of what the
|
||||
* query matches against. It keeps itself current while the command center stays open.
|
||||
*/
|
||||
function SubtitleWithTimestamp({
|
||||
query,
|
||||
subtitle,
|
||||
activityAt,
|
||||
testID,
|
||||
}: {
|
||||
query: string;
|
||||
subtitle: string;
|
||||
activityAt: Date | null;
|
||||
testID: string;
|
||||
}) {
|
||||
const timeAgo = useTimeAgo(activityAt);
|
||||
return (
|
||||
<View style={styles.subtitleLine}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={subtitle}
|
||||
style={[styles.subtitle, styles.subtitleText]}
|
||||
numberOfLines={1}
|
||||
testID={testID}
|
||||
/>
|
||||
{timeAgo ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-result-timestamp">
|
||||
{/* Non-breaking: web collapses a leading plain space in its own flex item. */}
|
||||
{subtitle ? `\u00a0· ${timeAgo}` : timeAgo}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
|
||||
function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
@@ -507,16 +438,13 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
<MaterialFileIcon fileName={result.title} size={16} />
|
||||
</View>
|
||||
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.fileName}
|
||||
testID="command-center-file-name"
|
||||
/>
|
||||
<Text style={styles.fileName} testID="command-center-file-name">
|
||||
{result.title}
|
||||
</Text>
|
||||
{result.subtitle ? (
|
||||
<Text style={styles.filePath} testID="command-center-file-path">
|
||||
{" "}
|
||||
<QueryHighlightedText query={query} text={result.subtitle} />
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
@@ -537,18 +465,12 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -559,28 +481,20 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View
|
||||
style={styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
<View style={styles.iconSlot}>
|
||||
<ThemedFolder size={16} strokeWidth={2.2} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.subtitle}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-workspace-subtitle"
|
||||
/>
|
||||
>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -598,19 +512,11 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={presentation.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{presentation.title}
|
||||
</Text>
|
||||
{presentation.subtitle ? (
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={presentation.subtitle}
|
||||
style={styles.subtitle}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -635,14 +541,14 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
style={styles.breadcrumbPart}
|
||||
>
|
||||
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={part}
|
||||
<Text
|
||||
style={
|
||||
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
|
||||
}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -657,9 +563,11 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
}
|
||||
|
||||
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
|
||||
const style = useMemo(() => [styles.section, { height: row.height }], [row.height]);
|
||||
let sizeStyle = styles.dividerSection;
|
||||
if (row.title && row.divider) sizeStyle = styles.dividedSection;
|
||||
if (row.title && !row.divider) sizeStyle = styles.titledSection;
|
||||
return (
|
||||
<View style={style}>
|
||||
<View style={sizeStyle}>
|
||||
{row.divider ? <View style={styles.sectionDivider} /> : null}
|
||||
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
|
||||
</View>
|
||||
@@ -668,10 +576,8 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const state = useCommandCenterState();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
@@ -738,13 +644,11 @@ export function CommandCenter() {
|
||||
) : (
|
||||
<ResultRow
|
||||
result={item.result}
|
||||
height={item.height}
|
||||
query={state.query}
|
||||
active={item.result.id === state.activeId}
|
||||
onSelect={state.select}
|
||||
/>
|
||||
),
|
||||
[state.activeId, state.query, state.select],
|
||||
[state.activeId, state.select],
|
||||
);
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
|
||||
@@ -997,21 +901,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: "500",
|
||||
},
|
||||
results: { flex: 1 },
|
||||
// Sections and rows take their height from list-metrics; these styles only place content in it.
|
||||
section: { justifyContent: "flex-end" },
|
||||
sectionLabel: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: COMMAND_CENTER_SECTION_GAP,
|
||||
paddingBottom: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionDivider: {
|
||||
height: 1,
|
||||
marginVertical: COMMAND_CENTER_SECTION_GAP,
|
||||
marginTop: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" },
|
||||
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
|
||||
tallRow: { height: 56 },
|
||||
activeRow: { backgroundColor: theme.colors.surface1 },
|
||||
rowContent: {
|
||||
flexDirection: "row",
|
||||
@@ -1030,31 +933,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.title,
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
},
|
||||
fileLine: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
},
|
||||
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 },
|
||||
fileName: { color: theme.colors.foreground },
|
||||
filePath: { color: theme.colors.foregroundMuted },
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle,
|
||||
},
|
||||
subtitleLine: { flexDirection: "row", minWidth: 0 },
|
||||
subtitleText: { flexShrink: 1, minWidth: 0 },
|
||||
iconSlot: {
|
||||
width: 16,
|
||||
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: { fontSize: 9 },
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 },
|
||||
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||
rowShortcut: { flexShrink: 0 },
|
||||
breadcrumb: {
|
||||
flex: 1,
|
||||
@@ -1072,7 +958,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
breadcrumbGroup: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
lineHeight: 20,
|
||||
flexShrink: 0,
|
||||
},
|
||||
emptyText: {
|
||||
@@ -1092,4 +978,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetBackground: { backgroundColor: theme.colors.surface0 },
|
||||
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
titledSection: { height: 32, justifyContent: "flex-end" },
|
||||
dividedSection: { height: 49, justifyContent: "flex-end" },
|
||||
dividerSection: { height: 17, justifyContent: "flex-end" },
|
||||
}));
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* Every vertical size in the Command Center list, in one place.
|
||||
*
|
||||
* The list is virtualized, so it needs each row's height before rendering it. Rather than keep
|
||||
* those numbers in sync with the styles by hand, rows render at exactly the height computed here
|
||||
* and center their content. The styles read the line heights below, so this arithmetic is the
|
||||
* layout.
|
||||
*
|
||||
* A layout chooses one thing: the inset above and below a row's text. Both layouts share the same
|
||||
* shape, so nothing downstream asks which one it got.
|
||||
*/
|
||||
export const COMMAND_CENTER_LINE_HEIGHT = {
|
||||
/** A row with no subtitle. Also the icon slot, so the icon centers on the title. */
|
||||
singleLine: 20,
|
||||
title: 18,
|
||||
subtitle: 16,
|
||||
sectionLabel: 16,
|
||||
} as const;
|
||||
|
||||
/** Space around a section's label and around the rule between sections. */
|
||||
export const COMMAND_CENTER_SECTION_GAP = SPACING[2];
|
||||
const SECTION_DIVIDER_THICKNESS = 1;
|
||||
|
||||
export interface CommandCenterListMetrics {
|
||||
singleLineRow: number;
|
||||
twoLineRow: number;
|
||||
/** A section label and the gaps around it. */
|
||||
sectionTitle: number;
|
||||
/** The rule between two sections and the gaps around it. */
|
||||
sectionDivider: number;
|
||||
}
|
||||
|
||||
function listMetrics(rowInset: number): CommandCenterListMetrics {
|
||||
const line = COMMAND_CENTER_LINE_HEIGHT;
|
||||
return {
|
||||
singleLineRow: rowInset + line.singleLine + rowInset,
|
||||
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
|
||||
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
|
||||
sectionDivider:
|
||||
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that
|
||||
* `density.rowHeight` uses on compact layouts.
|
||||
*/
|
||||
export const COMMAND_CENTER_LIST_METRICS = {
|
||||
desktop: listMetrics(SPACING[1.5]),
|
||||
compact: listMetrics(SPACING[3]),
|
||||
} as const satisfies Record<string, CommandCenterListMetrics>;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
@@ -102,28 +101,17 @@ function autoModeChoice(): CommandCenterContribution {
|
||||
});
|
||||
}
|
||||
|
||||
function workspace(
|
||||
id: string,
|
||||
title = id,
|
||||
subtitle = "host",
|
||||
activityAt: Date | null = null,
|
||||
): CommandCenterWorkspaceResult {
|
||||
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: null,
|
||||
activityAt,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): Date {
|
||||
return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000);
|
||||
}
|
||||
|
||||
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const section of sections) {
|
||||
@@ -132,8 +120,6 @@ function sectionResultIds(sections: ReturnType<typeof buildContributionSections>
|
||||
return ids;
|
||||
}
|
||||
|
||||
const metrics = COMMAND_CENTER_LIST_METRICS.desktop;
|
||||
|
||||
describe("Command Center result projection", () => {
|
||||
it("query-gates model choices and creates one flat row index", () => {
|
||||
const contributions = [
|
||||
@@ -144,19 +130,16 @@ describe("Command Center result projection", () => {
|
||||
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
|
||||
|
||||
const sections = buildContributionSections(contributions, "o");
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
...sections,
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("workspace:1")],
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
const projection = projectCommandCenterRows([
|
||||
...sections,
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("workspace:1")],
|
||||
},
|
||||
]);
|
||||
expect(projection.rows.map((row) => row.key)).toEqual([
|
||||
"section:models",
|
||||
"opus",
|
||||
@@ -164,12 +147,7 @@ describe("Command Center result projection", () => {
|
||||
"workspace:1",
|
||||
]);
|
||||
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
|
||||
expect(projection.offsets).toEqual([
|
||||
0,
|
||||
metrics.sectionTitle,
|
||||
metrics.sectionTitle + metrics.singleLineRow,
|
||||
metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle,
|
||||
]);
|
||||
expect(projection.offsets).toEqual([0, 32, 68, 117]);
|
||||
});
|
||||
|
||||
it("shows query-only actions only when their group matches a search", () => {
|
||||
@@ -199,18 +177,15 @@ describe("Command Center result projection", () => {
|
||||
|
||||
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
||||
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: "Workspaces",
|
||||
results: workspaces,
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
const projection = projectCommandCenterRows([
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: "Workspaces",
|
||||
results: workspaces,
|
||||
},
|
||||
]);
|
||||
|
||||
let activeId: string | null = workspaces[0].id;
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
@@ -220,7 +195,7 @@ describe("Command Center result projection", () => {
|
||||
const targetId = workspaces[150].id;
|
||||
expect(activeId).toBe(targetId);
|
||||
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
|
||||
expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow);
|
||||
expect(projection.offsets[151]).toBe(32 + 150 * 56);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,7 +248,7 @@ describe("Command Center relevance ordering", () => {
|
||||
schedulesAction(),
|
||||
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
|
||||
];
|
||||
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics);
|
||||
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
|
||||
|
||||
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||
@@ -326,19 +301,16 @@ describe("Command Center relevance ordering", () => {
|
||||
});
|
||||
|
||||
it("keeps Workspaces below contributions even on a perfect match", () => {
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
...buildContributionSections([schedulesAction()], "auto"),
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
const projection = projectCommandCenterRows([
|
||||
...buildContributionSections([schedulesAction()], "auto"),
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
|
||||
});
|
||||
@@ -352,7 +324,7 @@ describe("Command Center relevance ordering", () => {
|
||||
title: "Workspaces",
|
||||
results: [workspace("auto")],
|
||||
};
|
||||
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics);
|
||||
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
|
||||
|
||||
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||
"mode:auto",
|
||||
@@ -489,19 +461,15 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
visible: [row.title, row.subtitle],
|
||||
hidden: [],
|
||||
});
|
||||
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
|
||||
left.title.localeCompare(right.title);
|
||||
|
||||
it("shows the five most recent rows for an empty query", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) =>
|
||||
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
|
||||
);
|
||||
it("caps an empty query and preserves the caller's order", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"w7",
|
||||
"w6",
|
||||
"w5",
|
||||
"w4",
|
||||
"w3",
|
||||
]);
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
|
||||
});
|
||||
|
||||
it("puts the best match first even when it sorts last alphabetically", () => {
|
||||
@@ -512,9 +480,9 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
];
|
||||
|
||||
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
|
||||
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"c",
|
||||
]);
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("ranks a title match above a match that only the hidden field reached", () => {
|
||||
@@ -524,41 +492,24 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
? { visible: [row.title], hidden: ["deploy"] }
|
||||
: { visible: [row.title], hidden: [] };
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
|
||||
"title-hit",
|
||||
"hidden-hit",
|
||||
]);
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
|
||||
).toEqual(["title-hit", "hidden-hit"]);
|
||||
});
|
||||
|
||||
it("orders rows in the same match tier by recency, not by where the word sits", () => {
|
||||
// Both are whole-word matches for "usage"; the older one finds it earlier in the title.
|
||||
const rows = [
|
||||
workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)),
|
||||
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
|
||||
];
|
||||
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
|
||||
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
|
||||
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a better match tier above a more recent weaker match", () => {
|
||||
const rows = [
|
||||
workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)),
|
||||
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"whole-word",
|
||||
"subsequence",
|
||||
]);
|
||||
expect(
|
||||
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
|
||||
).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("drops a row when one token matches nothing", () => {
|
||||
const rows = [workspace("a", "Label as Design")];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user