Compare commits

..

1 Commits

Author SHA1 Message Date
Mohamed Boudra
ec9aecc123 fix(desktop): preserve Windows terminal hook smoke command
The outer batch shell expanded the hook variable and control operators before terminal send-keys received them. Encode the probe for PowerShell so expansion happens inside the packaged terminal.
2026-07-22 13:17:01 +02:00
490 changed files with 7401 additions and 26890 deletions

View File

@@ -18,106 +18,6 @@ env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Detect affected CI jobs
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
with:
filters: |
shared:
- '.github/workflows/ci.yml'
- '.github/actions/**'
- '.mise.toml'
- '.tool-versions'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'scripts/**'
- 'tsconfig.json'
- 'tsconfig.base.json'
- 'vitest.config.ts'
quality:
- 'packages/**'
- '*.cjs'
- '*.js'
- '*.json'
- '*.mjs'
- '*.ts'
server:
- 'packages/app/e2e/fixtures/recording.*'
- 'packages/client/**'
- 'packages/cli/src/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop:
- 'packages/app/**'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop_package:
- '.github/workflows/ci.yml'
- 'packages/desktop/**'
app:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
sdk:
- 'packages/client/**'
- 'packages/protocol/**'
- 'packages/relay/**'
playwright:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
relay:
- 'packages/relay/**'
cli:
- 'nix/**'
- 'packages/app/e2e/global-setup.ts'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/src/daemon/runtime-paths.ts'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format:
runs-on: ubuntu-latest
env:
@@ -139,12 +39,6 @@ jobs:
run: npx oxfmt --check .
lint:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -169,12 +63,6 @@ jobs:
run: npm run lint
typecheck:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -201,8 +89,6 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -211,43 +97,28 @@ jobs:
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -255,8 +126,6 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -265,53 +134,49 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
pull-requests: read
steps:
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- name: Detect desktop changes
if: matrix.os == 'ubuntu-latest'
id: desktop_changes
uses: dorny/paths-filter@v3
with:
filters: |
desktop:
- 'packages/desktop/**'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
if: failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -319,11 +184,7 @@ jobs:
retention-days: 7
- name: Build and smoke unpacked desktop app
if: >-
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
@@ -331,11 +192,7 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: >-
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-packaged-smoke-linux-x64
@@ -344,12 +201,6 @@ jobs:
retention-days: 7
app-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -374,12 +225,6 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -406,8 +251,6 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -421,57 +264,43 @@ jobs:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
if: ${{ !matrix.desktop }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: env.RUN_TESTS == 'true' && matrix.desktop
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure()
if: failure()
with:
name: playwright-results-${{ matrix.shard }}
path: |
@@ -480,12 +309,6 @@ jobs:
retention-days: 7
relay-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -507,8 +330,6 @@ jobs:
run: npm run test --workspace=@getpaseo/relay
cli-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -517,38 +338,21 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"

View File

@@ -1,10 +1,10 @@
[vars]
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
[env]
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[tools]
java = "21"

View File

@@ -1,77 +1,56 @@
# Changelog
## 0.2.3 - 2026-07-27
## 0.2.0-beta.2 - 2026-07-22
### Added
- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger))
- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371))
- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
### Improved
- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454))
- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480))
- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter))
- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter))
- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid))
- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
### Fixed
- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380))
- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482))
- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481))
- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458))
- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169))
- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter))
- `Shift+Tab` no longer changes a background agents permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter))
- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter))
- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476))
- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
## 0.2.2 - 2026-07-25
### Fixed
- Claude 5 models now use the correct context windows.
## 0.2.1 - 2026-07-24
### Added
- Claude Opus 5 is available
## 0.2.0 - 2026-07-24
## 0.2.0-beta.1 - 2026-07-17
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- ACP provider catalog updated to the latest registry versions
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
@@ -79,34 +58,12 @@
### Fixed
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16

View File

@@ -151,12 +151,23 @@ npm run build:server
npm run typecheck
```
## 関連プロジェクト
## コミュニティ
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## ライセンス
AGPL-3.0

View File

@@ -38,6 +38,12 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
@@ -138,7 +144,7 @@ Quick monorepo package map:
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Electron desktop app
- `packages/relay`: Relay transport and encryption used by the daemon and clients
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)
Common commands:
@@ -160,12 +166,23 @@ npm run build:server
npm run typecheck
```
## Related projects
## Community
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0

View File

@@ -151,10 +151,9 @@ npm run build:server
npm run typecheck
```
## 相关项目
## 社区
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay使用 Elixir 编写
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS
@@ -203,6 +202,18 @@ server {
}
```
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0

View File

@@ -22,9 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -50,12 +48,6 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
destination-link escapes, but the linked source is a deliberate shared-data boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root

View File

@@ -21,8 +21,15 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. Subagents are considered independently; collection
does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
### Cancellation
@@ -51,8 +58,9 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
@@ -72,20 +80,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
## Tabs vs archive
These are two distinct concepts that used to be conflated:

View File

@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
@@ -120,16 +120,6 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
```bash
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.

View File

@@ -121,7 +121,6 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
@@ -133,19 +132,16 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
Communicates with the daemon via the same WebSocket protocol as the app.
### `packages/relay` — Relay transport and E2E encryption
### `packages/relay` — E2E encrypted relay
Enables remote access when the daemon is behind a firewall.
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### Paseo Hub
@@ -212,9 +208,7 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
@@ -252,10 +246,6 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
messages or transfers.
### Compatibility rules
@@ -387,5 +377,5 @@ $PASEO_HOME/
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
2. **Managed desktop**: Electron app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

View File

@@ -29,7 +29,6 @@ Root checkout dev is intentionally split across terminals:
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -243,14 +242,6 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from `process.env` and invoke it as
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
```json
{
"worktree": {
@@ -392,14 +383,13 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -410,11 +400,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:

View File

@@ -176,9 +176,8 @@ IPs and `localhost` are allowed by default.
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
- Prefer HTTPS at the reverse proxy for direct browser access.
- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for
untrusted networks or mobile access when you do not want to expose the daemon
port directly.
- Use the Paseo relay for untrusted networks or mobile access when you do not
want to expose the daemon port directly.
- The container is the isolation boundary for agents. Agents can read and write
whatever you mount into `/workspace` and whatever credentials you place in
`/home/paseo`.

View File

@@ -73,20 +73,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.

View File

@@ -78,29 +78,6 @@ ordinary portals regardless of `z-index`, which would hide app toasts and
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
lets tooltip portals paint above both.
The shared overlay scale is relative for interactive surfaces: a base floating
panel is below a base modal, while a floating panel rendered from inside a modal
inherits that modal's layer and paints above it. Wrap portal content in
`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
comboboxes must use `overlay-root` too. Rendering them through React Native
Web's `<Modal>` puts them in the browser top layer, where no ordinary modal
portal can cover them.
Painting and keyboard ownership use the same relative layer model. Register
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
highest painted scope alone receives overlay keys, traps focus, and restores
focus when it closes. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
If an overlay is rendered by a global host rather than beneath its opener in
the React tree, carry the opener's current layer through the host store and
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
ownership silently reset at the app root. When the opener is a global keyboard
action and has no component context to carry, resolve the host layer with
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
current top registered layer before the new host joins the stack; do not give a
global dialog a fixed root-derived modal layer.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
@@ -152,13 +129,6 @@ lockstep, no re-measurement needed. Do not call
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.

View File

@@ -45,22 +45,8 @@ replays the original prompt. A duplicate create returns the existing agent witho
turn.
Hub creates use the same agent creation path as trusted clients. They may select any existing
worktree target shape. Execution completion policy remains outside the daemon: a completed agent
turn does not imply that the Hub execution is terminal.
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
actions are idempotent and continue to resolve from stored ownership after daemon restart.
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
workspace through the shared workspace archive service, so the backing directory is removed only
after its final active workspace reference disappears. Local and shared checkouts archive only the
execution-owned agent.
worktree target shape and may request `autoArchive`. Worktree creation and terminal auto-archive use
the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path.
## Disconnect and revocation

View File

@@ -43,8 +43,7 @@ This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when a client disconnects — the daemon owns them, not the client
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
- Agents keep running when you close the app
## Target user

View File

@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.

View File

@@ -38,14 +38,6 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -84,23 +76,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
@@ -114,7 +92,6 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -529,7 +506,6 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green

View File

@@ -26,18 +26,6 @@ dev--feature-auth--miniweb.localhost
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Managing workspace scripts
Configured `paseo.json` scripts can be managed without addressing their backing terminal directly:
```bash
paseo script ls [--cwd <path> | --workspace <workspace-id>]
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
```
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
@@ -107,29 +95,6 @@ server {
}
```
Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here.
## Forwarded headers
Paseo sets these when it forwards a request to a workspace service:
| Header | Value |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one |
| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) |
| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set |
`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched.
Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`.
### The forwarded authority is not authenticated
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through.
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port.
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:

View File

@@ -32,14 +32,9 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1`
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.

View File

@@ -1 +1 @@
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA=

117
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,19 +36236,18 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/server": "0.2.3",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/server": "0.2.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
"tree-kill": "^1.2.2",
"ws": "^8.14.2",
"yaml": "^2.8.4",
"zod": "^4.4.3"
"yaml": "^2.8.4"
},
"bin": {
"paseo": "bin/paseo"
@@ -36488,10 +36487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36502,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36745,7 +36744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37641,7 +37640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37873,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37886,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38104,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.3",
"@getpaseo/highlight": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/highlight": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38156,22 +38155,22 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz",
"integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -38180,9 +38179,9 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz",
"integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==",
"cpu": [
"arm64"
],
@@ -38193,9 +38192,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz",
"integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==",
"cpu": [
"x64"
],
@@ -38206,9 +38205,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz",
"integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==",
"cpu": [
"arm64"
],
@@ -38219,9 +38218,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz",
"integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==",
"cpu": [
"arm64"
],
@@ -38232,9 +38231,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz",
"integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==",
"cpu": [
"x64"
],
@@ -38245,9 +38244,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz",
"integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==",
"cpu": [
"x64"
],
@@ -38258,9 +38257,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz",
"integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==",
"cpu": [
"arm64"
],
@@ -38271,9 +38270,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz",
"integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==",
"cpu": [
"x64"
],
@@ -38649,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -49,9 +49,9 @@
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",

View File

@@ -1,49 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});

View File

@@ -1,10 +1,7 @@
import { test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
makeLoadedTimelineFitViewport,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
@@ -28,21 +25,4 @@ test.describe("Agent timeline pagination", () => {
await agent.cleanup();
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});

View File

@@ -1,115 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});

View File

@@ -123,16 +123,4 @@ test.describe("mobile bottom sheet reopen", () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await sheet.getByText("Ten second stream", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});

View File

@@ -55,15 +55,9 @@ test.describe("Command center workspaces", () => {
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
await expect(row).toContainText(WORKSPACE_BRANCH);
// The subtitle disambiguates by project: host · project · branch (multi-host).
const subtitle = row.getByTestId("command-center-workspace-subtitle");
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
await expect(subtitle).toContainText(seeded.projectDisplayName);
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
// The agent subtitle is unchanged by the shared-helper refactor.
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
@@ -95,10 +89,6 @@ test.describe("Command center workspaces", () => {
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
// The project name is now part of the workspace searchText.
await input.fill(seeded.projectDisplayName);
await expect(row).toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
@@ -113,38 +103,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();
}
});
});

View File

@@ -5,25 +5,6 @@ import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history explains when the workspace has no commits ahead of its base", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
"No commits ahead of main yet",
{ timeout: 30_000 },
);
});
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
@@ -42,26 +23,31 @@ test("commit history shows dates and shares diff layout preferences", async ({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);

View File

@@ -1,4 +1,4 @@
import { unlink, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
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("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
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.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({

View File

@@ -18,14 +18,6 @@ function editor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function hasHorizontalOverflow(element: HTMLElement): boolean {
return element.scrollWidth > element.clientWidth;
}
function fitsViewportWidth(element: HTMLElement): boolean {
return element.scrollWidth === element.clientWidth;
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const contentElement = editor(page);
await contentElement.click();
@@ -103,34 +95,6 @@ test.describe("CodeMirror workspace file editing", () => {
}
});
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink(target);
try {
await page.setViewportSize({ width: 1280, height: 900 });
await openAgentRoute(page, session);
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await openWorkspaceFile(page, "target.ts");
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
.filter({ visible: true })
.click();
await editor(page).click();
await page.keyboard.press("Alt+Shift+W");
await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible();
await expect(
page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }),
).toBeVisible();
} finally {
await session.cleanup();
}
});
test("shows the full file path and keeps editor controls stable", async ({
page,
withWorkspace,
@@ -210,36 +174,6 @@ test.describe("CodeMirror workspace file editing", () => {
).toHaveCSS("font-family", "monospace");
});
test("wraps Markdown while source code remains horizontally scrollable", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-wrap-" });
const longLine = "word ".repeat(300);
await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8");
await writeFile(
path.join(workspace.repoPath, "source.ts"),
`const value = "${longLine}";\n`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await page.getByTestId("file-mode-source").click();
const markdownScroller = page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-scroller");
await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true);
await openWorkspaceFile(page, "source.ts");
const sourceScroller = page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-scroller");
await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true);
});
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
page,
withWorkspace,
@@ -305,29 +239,6 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(editor(page)).toContainText("const afterReconnect = 9;");
});
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
const sourcePath = path.join(workspace.repoPath, "windows.ts");
await writeFile(
sourcePath,
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "windows.ts");
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
await editor(page).press("Control+s");
const expected = Buffer.from(
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
"utf8",
).toString("hex");
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
});
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
const sourcePath = path.join(workspace.repoPath, "draft.ts");

View File

@@ -10,11 +10,6 @@ interface CreatedAgentTimelineGate {
waitForForwardedResponse(): Promise<void>;
}
export interface AgentTimelineResponseGate {
release(): void;
waitForDelayedResponse(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -123,53 +118,3 @@ export async function delayCreatedAgentInitialTailResponse(
waitForForwardedResponse: () => forwardedResponse,
};
}
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
const delayedForwards: Array<() => void> = [];
let resolveDelayedResponse: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayedResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForDelayedResponse: () => delayedResponse,
};
}

View File

@@ -177,14 +177,6 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await expect(searchInput).toBeFocused();
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
projectDisplayName: string,

View File

@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;

View File

@@ -1,9 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentOlderTimelineResponse,
type AgentTimelineResponseGate,
} from "./agent-timeline-gate";
interface LongTimelineAgentOptions {
turns: number;
@@ -57,39 +53,6 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 8_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight <= element.clientHeight;
}),
)
.toBe(true);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
return {
...gate,
async expectLoading() {
await gate.waitForDelayedResponse();
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
},
};
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();

View File

@@ -100,7 +100,11 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
const option = page
.getByRole("dialog")
.last()
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
.first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });

View File

@@ -5,7 +5,6 @@ import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
openNewWorkspaceProjectPickerWithShortcut,
} from "./helpers/new-workspace";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -107,20 +106,6 @@ test.describe("New workspace entry points", () => {
}
});
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,
}) => {

View File

@@ -1,146 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute } from "./helpers/mock-agent";
import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject } from "./helpers/new-workspace";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
// The draft mode control in New Workspace only mutates local form state; it never sends
// set_agent_mode_request. So the only source of such a request while the New Workspace
// composer is focused is a *live* agent's mode control. Recording those, keyed by agentId,
// gives a direct signal that Shift+Tab leaked into a backgrounded agent.
async function recordSetAgentModeRequests(page: Page): Promise<{
requestsForAgent(agentId: string): Array<{ agentId: string; modeId: string }>;
}> {
const seen: Array<{ agentId: string; modeId: string }> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "set_agent_mode_request") {
const agentId = typeof sessionMessage.agentId === "string" ? sessionMessage.agentId : "";
const modeId = typeof sessionMessage.modeId === "string" ? sessionMessage.modeId : "";
seen.push({ agentId, modeId });
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
requestsForAgent: (agentId: string) => seen.filter((request) => request.agentId === agentId),
};
}
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: { "gpt-5.4-mini": "low" },
},
mock: { model: "ten-second-stream" },
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
// Focus the New Workspace composer and cycle the execution mode with the keyboard.
// Kept out of the test body so the test reads as intent rather than key mechanics.
async function cycleNewWorkspaceMode(page: Page, presses: number): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.click();
for (let i = 0; i < presses; i++) {
await page.keyboard.press("Shift+Tab");
}
}
test.describe("New Workspace mode cycle safety", () => {
test.describe.configure({ timeout: 240_000 });
// Regression guard for the P1 safety bug: cycling the execution mode with Shift+Tab in
// the New Workspace composer must never reach a backgrounded, still-mounted agent's mode
// control and silently change that (possibly running) agent's mode — e.g. into a
// permissive/bypass mode. See use-keyboard-action-handler.ts.
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
await seedCodexDefaultPreferences(page, serverId);
const modeRequests = await recordSetAgentModeRequests(page);
try {
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "mode cycle safety e2e",
modeId: "auto",
model: "gpt-5.4-mini",
});
// Mount the live agent tab: its mode control registers a mode-cycle keyboard handler.
await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id });
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
// Move to the New Workspace composer. The agent tab stays mounted in the background,
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await cycleNewWorkspaceMode(page, 6);
// fetchAgents is a real daemon round-trip; once it resolves, any mode change the
// presses would have triggered has already landed. Assert the running agent is
// untouched — both its committed mode and on the wire — with no fixed sleep.
const agents = await seeded.client.fetchAgents();
const backgroundAgent = agents.entries.find((entry) => entry.agent.id === agent.id)?.agent;
expect(backgroundAgent?.currentModeId).toBe("auto");
expect(modeRequests.requestsForAgent(agent.id)).toEqual([]);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -1,4 +1,3 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
@@ -64,33 +63,6 @@ async function closeSheetByHeaderButton(page: Page, testId: string) {
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) {
const frontCoversBack = await page.evaluate(
({ frontTestId: frontId, backTestId: backId }) => {
const front = document.querySelector(`[data-testid="${frontId}"]`);
const back = document.querySelector(`[data-testid="${backId}"]`);
if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false;
const frontRect = front.getBoundingClientRect();
const backRect = back.getBoundingClientRect();
const left = Math.max(frontRect.left, backRect.left);
const right = Math.min(frontRect.right, backRect.right);
const top = Math.max(frontRect.top, backRect.top);
const bottom = Math.min(frontRect.bottom, backRect.bottom);
if (left >= right || top >= bottom) return false;
const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
return topElement != null && front.contains(topElement);
},
{ frontTestId, backTestId },
);
expect(frontCoversBack).toBe(true);
}
async function hasFocusWithin(locator: Locator): Promise<boolean> {
return locator.evaluate((element) => element.contains(document.activeElement));
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
@@ -117,69 +89,7 @@ async function exerciseProviderSettingsStack(page: Page) {
await expectProviderSettingsVisible(page);
}
test.describe("provider settings overlay stack", () => {
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-modal-layer-",
title: "Provider modal layer e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await page.getByRole("button", { name: /Select model/ }).click();
const selector = page.getByTestId("combobox-desktop-container");
await expect(selector).toBeVisible({ timeout: 10_000 });
const searchInput = page.getByRole("textbox", { name: /search models/i });
await expect(searchInput).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect.poll(() => hasFocusWithin(selector)).toBe(true);
await page.keyboard.press("Shift+?");
const shortcuts = page.getByTestId("keyboard-shortcuts-dialog");
await expect(shortcuts).toBeVisible({ timeout: 10_000 });
await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused();
await page.keyboard.press("Escape");
await expect(shortcuts).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
await page.keyboard.press("ControlOrMeta+K");
const commandCenter = page.getByTestId("command-center-panel");
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
await expect(commandCenter.getByTestId("command-center-input")).toBeFocused();
await page.keyboard.press("Escape");
await expect(commandCenter).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
await page.keyboard.press("ControlOrMeta+K");
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
await commandCenter.getByText("Add project", { exact: true }).click();
const addProject = page.getByTestId("add-project-flow");
await expect(addProject).toBeVisible({ timeout: 10_000 });
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
await page.keyboard.press("Escape");
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
const settingsButton = page.getByTestId("selector-header-settings-mock");
await settingsButton.click();
const settings = page.getByTestId("provider-settings-sheet");
await expect(settings).toBeVisible({ timeout: 10_000 });
await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container");
await page.keyboard.press("Escape");
await expect(settings).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
await expect(settingsButton).toBeFocused();
} finally {
await session.cleanup();
}
});
test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {

View File

@@ -1,266 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
// whenever the row was unmounted — a collapsed project section being the common case. It now
// lives in a single always-mounted handler keyed on the active route selection.
const PIN_SHORTCUT = "ControlOrMeta+Shift+P";
function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function pinnedSection(page: Page) {
return page.getByTestId("sidebar-pinned-section");
}
// Opens the workspace so it becomes the active route selection, which is what the shortcut acts on.
async function openWorkspace(page: Page, workspaceId: string) {
const row = workspaceRow(page, workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
// The project key is host-scoped and not exposed by the seed helper, so the header is addressed by
// its display name, scoped to project rows so a workspace row can never match. Pressing the header
// toggles the section, which unmounts every workspace row under it.
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: project.projectDisplayName });
await expect(header).toHaveCount(1, { timeout: 30_000 });
await header.click();
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
async function switchToStatusGrouping(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
}
// Status mode buckets workspaces by state rather than project, so the group holding this workspace
// is discovered from the rows container it sits in rather than assumed.
async function collapseStatusGroupContaining(page: Page, workspaceId: string): Promise<void> {
const rows = page
.locator('[data-testid^="sidebar-status-group-rows-"]')
.filter({ has: workspaceRow(page, workspaceId) });
await expect(rows).toHaveCount(1, { timeout: 30_000 });
const rowsTestId = await rows.getAttribute("data-testid");
const bucket = rowsTestId?.replace("sidebar-status-group-rows-", "");
expect(bucket).toBeTruthy();
await page.getByTestId(`sidebar-status-group-${bucket}`).click();
await expect(workspaceRow(page, workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
function readSessionMessage(
message: string | Buffer,
): { type?: unknown; requestId?: unknown } | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
if (envelope.type !== "session" || typeof envelope.message !== "object") {
return null;
}
return envelope.message as { type?: unknown; requestId?: unknown };
} catch {
return null;
}
}
const PIN_REJECTION_MESSAGE = "Pin rejected by test.";
interface PinRpcGate {
/** Pin requests the client has sent so far. */
sentCount(): number;
}
// Proxies everything so the app boots against the real daemon, counting pin RPCs and optionally
// rejecting the first `rejectFirst` of them. The count asserts how many pins one keypress actually
// dispatched, which the rendered pin state cannot show — a toggle that fired twice lands back
// where it started.
async function installPinRpcGate(
page: Page,
options: { rejectFirst?: number } = {},
): Promise<PinRpcGate> {
const rejectFirst = options.rejectFirst ?? 0;
let sent = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = readSessionMessage(message);
if (
sessionMessage?.type === "workspace.pin.set.request" &&
typeof sessionMessage.requestId === "string"
) {
sent += 1;
if (sent <= rejectFirst) {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: sessionMessage.requestId,
requestType: "workspace.pin.set.request",
error: PIN_REJECTION_MESSAGE,
code: "transport",
},
},
}),
);
return;
}
}
try {
server.send(message);
} catch {
// server socket already closed
}
});
server.onMessage((message) => {
try {
ws.send(message);
} catch {
// client socket already closed
}
});
});
return { sentCount: () => sent };
}
test.describe("Pin workspace shortcut", () => {
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await collapseProjectSection(page, workspace);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await expect(
pinnedSection(page).getByTestId(
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
),
).toBeVisible();
} finally {
await workspace.cleanup();
}
});
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await page.getByTestId("sidebar-pinned-section-header").click();
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
} finally {
await workspace.cleanup();
}
});
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
// shadowed second handler is unobservable from outside by design.
expect(gate.sentCount()).toBe(1);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await switchToStatusGrouping(page);
await collapseStatusGroupContaining(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await expect(
pinnedSection(page).getByTestId(
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
),
).toBeVisible();
} finally {
await workspace.cleanup();
}
});
test("shows an error toast when the host rejects the pin, and the next press succeeds", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-failure-" });
try {
const gate = await installPinRpcGate(page, { rejectFirst: 1 });
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(page.getByTestId("app-toast-message")).toContainText(PIN_REJECTION_MESSAGE, {
timeout: 10_000,
});
await expect(pinnedSection(page)).toHaveCount(0);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
// The failure must leave the action usable: the in-flight guard has to release the key so a
// retry is not swallowed. Without that release the workspace is unpinnable for the session.
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -1,238 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
label: string;
renderedWidth: number;
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let releaseSecondRequested = false;
let pauseServerMessages = false;
let secondRunningForwarded = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (!secondRunningMessage || secondRunningForwarded) {
return;
}
ws.send(secondRunningMessage);
secondRunningForwarded = true;
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseServerMessages) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
if (toolCall.status === "running" || toolCall.status === "executing") {
return;
}
if (toolCall.status === "completed") {
// The mock tool completes inside the daemon's coalescing window, so the
// browser naturally receives its authoritative completed state first.
ws.send(message);
resolveFirstCompleted();
return;
}
}
secondCallId ??= toolCall.callId;
if (
toolCall.callId === secondCallId &&
(toolCall.status === "running" || toolCall.status === "executing")
) {
secondRunningMessage = message;
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
// Keep the second real tool call inspectably active. Production providers
// send this same status shape while their work remains in flight.
secondRunningMessage = replaceToolCallStatus(message, "running");
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
if (secondRunningMessage) {
forwardSecondRunning?.();
}
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
);
if (!shimmer) {
throw new Error("Expected a running shimmer inside the badge");
}
const style = getComputedStyle(shimmer);
return {
animationDuration: style.animationDuration,
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
label: shimmer.textContent ?? "",
renderedWidth: shimmer.getBoundingClientRect().width,
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
};
});
}
test("measures an overview heading that becomes loading after its idle mount", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "tool-call-shimmer-",
title: "Tool-call shimmer",
model: "ten-second-stream",
});
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group");
await expect(group).toBeVisible();
const idleGroupHandle = await group.elementHandle();
if (!idleGroupHandle) {
throw new Error("Expected the idle tool-call group to be mounted");
}
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
await gate.waitForSecondRunning();
gate.releaseSecondRunning();
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
const sameGroupNode = await group.evaluate(
(node, previous) => node === previous,
idleGroupHandle,
);
await group.click();
const runningChild = page.getByTestId("tool-call-badge").last();
await expect(runningChild).toBeVisible();
const header = await readShimmerEvidence(group);
const child = await readShimmerEvidence(runningChild);
const evidence = { sameGroupNode, header, child };
await testInfo.attach("tool-call-shimmer-evidence", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(sameGroupNode).toBe(true);
expect(child.endPx).toBeGreaterThan(0);
expect(
header.endPx,
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
).toBeGreaterThan(0);
} finally {
await agent.cleanup();
}
});

View File

@@ -1,8 +1,4 @@
import {
buildHostAgentDetailRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -38,7 +34,6 @@ import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "./helpers/hosts";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
type StartupPresentation = "splash" | "app";
@@ -163,43 +158,6 @@ async function expectWorkspaceLocation(
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
agentId: "notification-agent",
};
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: target.serverId,
label: "Notification Host",
});
await expect(
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.evaluate((data) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: { data: { ...data, reason: "finished" } },
cancelable: true,
}),
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
@@ -462,13 +420,12 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceDeckEntryCount(page, 2);
await page.evaluate(
({ agentId, serverId: targetServerId, workspaceId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
workspaceId,
agentId,
reason: "finished",
},
@@ -477,7 +434,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -36,52 +36,7 @@ async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<str
return terminal.id;
}
async function cleanupTerminal(
workspace: SeededWorkspace,
terminalId: string | null,
): Promise<void> {
if (terminalId) {
await workspace.client.killTerminal(terminalId).catch(() => undefined);
}
await workspace.cleanup();
}
async function readClipboard(page: Page): Promise<string> {
return page.evaluate(() => navigator.clipboard.readText());
}
test.describe("Workspace terminal tab rename", () => {
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
context,
page,
}) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
let terminalId: string | null = null;
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId(
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
);
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
await expect.poll(() => readClipboard(page)).toBe(terminalId);
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
test("right-click rename persists the terminal title and updates the tab label", async ({
page,
}) => {
@@ -119,7 +74,10 @@ test.describe("Workspace terminal tab rename", () => {
.poll(() => fetchTerminalTitle(workspace, terminalId!))
.toBe("My Renamed Terminal");
} finally {
await cleanupTerminal(workspace, terminalId);
if (terminalId) {
await workspace.client.killTerminal(terminalId).catch(() => undefined);
}
await workspace.cleanup();
}
});
});

View File

@@ -44,10 +44,7 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
async function createArchivedMissingWorktree(prefix: string) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
@@ -56,19 +53,11 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
@@ -78,21 +67,11 @@ test.describe("Worktree restore", () => {
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, agents, worktree };
return { agent, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
@@ -262,54 +241,6 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;

View File

@@ -19,9 +19,6 @@
"channel": "production",
"env": {
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large"
}
},
"production-apk": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -11,51 +11,6 @@ const EXCLUDED_ANDROID_MODULES = [
"expo-dev-menu-interface",
];
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
def paseoAbiVersionCodes = [
"armeabi-v7a": 1,
"arm64-v8a": 2,
"x86": 3,
"x86_64": 4,
]
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
.toString()
.split(",")
.collect { it.trim() }
.findAll { !it.isEmpty() }
if (paseoArchitectures.size() == 1) {
def paseoAbi = paseoArchitectures[0]
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
if (paseoAbiVersionCode == null) {
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
}
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
}
`;
function configureFdroidAppBuildGradle(contents) {
let configuredContents = contents;
if (!configuredContents.includes("dependenciesInfo {")) {
const androidBlock = "android {";
if (!configuredContents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
configuredContents = configuredContents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
}
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
}
return configuredContents;
}
function withFdroidAutolinking(config) {
config = withDangerousMod(config, [
"android",
@@ -110,7 +65,19 @@ function withFdroidAutolinking(config) {
});
return withAppBuildGradle(config, (modConfig) => {
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
return modConfig;
}
const androidBlock = "android {";
if (!modConfig.modResults.contents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
return modConfig;
});
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
@@ -126,15 +126,10 @@ function createDriverHarness(input?: {
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollAttempts: boolean[] = [];
let scrollToBottomBehavior = () => {
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
};
const scrollToBottom = (animated: boolean) => {
scrollAttempts.push(animated);
scrollToBottomBehavior();
};
});
const modeChanges: BottomAnchorMode[] = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
@@ -155,10 +150,7 @@ function createDriverHarness(input?: {
context,
driver,
scheduler,
scrollAttempts,
setScrollToBottomBehavior(next: () => void) {
scrollToBottomBehavior = next;
},
scrollToBottom,
modeChanges,
};
}
@@ -218,7 +210,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
@@ -237,7 +229,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
@@ -245,97 +237,6 @@ describe("bottom anchor controller driver", () => {
});
});
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("preserves a blocked route anchor when layout moves after drag release", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("lets a user scroll away supersede a blocked route anchor", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isNearBottom: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
@@ -353,74 +254,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 1,
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.endUserScroll({ isNearBottom: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -437,7 +271,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
@@ -458,7 +292,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -479,7 +313,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
@@ -492,7 +326,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -504,7 +338,7 @@ describe("bottom anchor controller driver", () => {
},
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -514,16 +348,16 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -541,7 +375,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
@@ -552,7 +386,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
@@ -575,7 +409,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
@@ -601,7 +435,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
@@ -637,7 +471,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
@@ -646,7 +480,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
@@ -658,7 +492,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -689,7 +523,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
@@ -699,7 +533,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -743,7 +577,7 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -754,7 +588,7 @@ describe("bottom anchor controller driver", () => {
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -791,14 +625,14 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {

View File

@@ -68,8 +68,6 @@ interface BottomAnchorControllerDriver {
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -245,7 +243,6 @@ function createBottomAnchorControllerDriver(
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
let isUserScrollActive = false;
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
@@ -414,14 +411,10 @@ function createBottomAnchorControllerDriver(
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
) => {
if (isUserScrollActive) {
return;
}
if (attemptHandle) {
return;
}
@@ -488,7 +481,6 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
@@ -505,38 +497,6 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
},
endUserScroll(params) {
isUserScrollActive = false;
if (params.isNearBottom) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
if (pendingRequest) {
evaluate(false, "user_scroll_end");
return;
}
if (
!input.isNearBottom() ||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
markStickyMeasurementVerified();
return;
}
if (mode === "sticky-bottom") {
this.detachByUser();
}
},
detachByUser() {
if (mode === "detached") {
return;
@@ -551,9 +511,6 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
@@ -572,9 +529,6 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
@@ -604,9 +558,6 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
@@ -620,9 +571,6 @@ function createBottomAnchorControllerDriver(
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -789,12 +737,6 @@ export function useBottomAnchorController(input: {
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isNearBottom: boolean }) {
driverRef.current?.endUserScroll(params);
},
detachByUser() {
driverRef.current?.detachByUser();
},

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
const visibleHistoryStart = {
distanceFromHistoryStart: 0,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "epoch-1:20",
};
describe("history start pagination", () => {
it("loads once for each authoritative history cursor", () => {
const initial = createHistoryStartPaginationState();
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
const nextPage = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
true,
false,
true,
]);
});
it("allows the same revision again after the user leaves the history edge", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const away = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 200,
});
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
});
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(first.state),
visibleHistoryStart,
);
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
});
it("waits while history loading is unavailable or already active", () => {
const state = createHistoryStartPaginationState();
expect([
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
.shouldLoad,
]).toEqual([false, false, false, false]);
});
});

View File

@@ -1,45 +0,0 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export interface HistoryStartPaginationState {
requestedProgressKey: string | null;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { requestedProgressKey: null };
}
export function rearmHistoryStartPagination(
_state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return createHistoryStartPaginationState();
}
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
},
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
return { state: createHistoryStartPaginationState(), shouldLoad: false };
}
if (
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return { state, shouldLoad: false };
}
if (state.requestedProgressKey === input.progressKey) {
return { state, shouldLoad: false };
}
return {
state: { requestedProgressKey: input.progressKey },
shouldLoad: true,
};
}

View File

@@ -1,139 +0,0 @@
import { describe, expect, it } from "vitest";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
type ScrollKeyboardDismissEvent,
type ScrollKeyboardDismissGesture,
} from "./model";
interface Point {
ts: number;
y: number;
nativeTs?: number | null;
}
function event(point: Point): ScrollKeyboardDismissEvent {
return {
timeStamp: point.ts,
nativeEvent: {
contentOffset: { y: point.y },
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
},
};
}
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
return points.reduce(
(gesture, point) => recordScroll(gesture, event(point)),
beginDrag(event(start)),
);
}
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
}
describe("scroll keyboard dismissal", () => {
it("keeps the keyboard up for a slow read-scroll", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
it("keeps a fast-but-decelerating drag below the release threshold", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
expect(
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});

View File

@@ -1,124 +0,0 @@
/**
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
const RELEASE_SAMPLE_MIN_MS = 30;
/**
* The subset of a native scroll event used by the classifier. React Native's
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
*/
export interface ScrollKeyboardDismissEvent {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}
interface DragSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}
export type ScrollKeyboardDismissGesture =
| { phase: "idle" }
| { phase: "dragging"; samples: DragSamples };
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
phase: "idle",
});
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
const timestamp = resolveEventTimeMs(event);
const offsetY = event.nativeEvent.contentOffset.y;
return {
phase: "dragging",
samples: {
startTs: timestamp,
startY: offsetY,
sampleTs: timestamp,
sampleY: offsetY,
previousSampleTs: 0,
previousSampleY: 0,
},
};
}
export function recordScroll(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): ScrollKeyboardDismissGesture {
if (gesture.phase === "idle") {
return gesture;
}
const timestamp = resolveEventTimeMs(event);
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
return gesture;
}
const { samples } = gesture;
return {
phase: "dragging",
samples: {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: timestamp,
sampleY: event.nativeEvent.contentOffset.y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
},
};
}
export function releaseDrag(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
if (gesture.phase === "idle") {
return { gesture, shouldDismiss: false };
}
const releaseTs = resolveEventTimeMs(event);
const releaseY = event.nativeEvent.contentOffset.y;
const { samples } = gesture;
// The release event carries the gesture's true endpoint. The final onScroll
// event can still be stale when a short flick lands.
let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}

View File

@@ -1,57 +0,0 @@
import { useRef } from "react";
import {
Keyboard,
TextInput,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
} from "./model";
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
const { shift } = useKeyboardShift();
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
gestureRef.current = beginDrag(event);
});
const onScroll = useStableEvent((event: ScrollEvent) => {
gestureRef.current = recordScroll(gestureRef.current, event);
});
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
const release = releaseDrag(gestureRef.current, event);
gestureRef.current = release.gesture;
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
// second calculation on JS, this prevents a hardware keyboard's focused
// composer from being blurred when no software keyboard occupies space.
if (!release.shouldDismiss || shift.value <= 0) {
return;
}
// Keep blur and dismiss paired: this exact sequence was validated on a
// physical Android device to clear both input focus and the IME inset.
const focusedInput = TextInput.State.currentlyFocusedInput();
if (focusedInput) {
TextInput.State.blurTextInput(focusedInput);
}
Keyboard.dismiss();
});
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
}

View File

@@ -9,6 +9,7 @@ import {
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
@@ -16,43 +17,23 @@ import {
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
} from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const historyStartSlotStyle: ViewStyle = {
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -91,7 +72,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
@@ -109,14 +89,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -145,23 +121,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const evaluateHistoryStart = useStableEvent(() => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
});
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
@@ -170,13 +129,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
userScrollEndFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -256,21 +208,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
const keyboardEvents = [
@@ -319,25 +266,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
});
},
);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
scrollKeyboardDismiss.onScroll(event);
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
@@ -348,16 +280,28 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isScrollEventNearBottom(event);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
evaluateHistoryStart();
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
@@ -368,51 +312,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
});
});
const handleMomentumScrollBegin = useStableEvent(() => {
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
// Android can emit momentum-end after a programmatic anchor correction.
// Only momentum that still owns the user gesture may settle scroll intent.
if (!isUserScrollActiveRef.current) {
return;
}
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
},
);
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
@@ -437,7 +336,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
previousViewportHeight,
viewportHeight,
});
evaluateHistoryStart();
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
@@ -453,13 +351,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
previousContentHeight,
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
});
useEffect(() => {
evaluateHistoryStart();
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyItems);
@@ -499,20 +392,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
]);
const historyFooterContent = useMemo(() => {
if (!hasOlderHistory && !isLoadingOlderHistory) {
if (!isLoadingOlderHistory) {
return null;
}
return (
<View
style={historyStartSlotStyle}
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<View testID="load-older-history-spinner">
<ActivityIndicator size="small" />
</View>
);
}, [hasOlderHistory, isLoadingOlderHistory]);
}, [isLoadingOlderHistory]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
@@ -531,10 +419,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}

View File

@@ -133,7 +133,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -177,7 +176,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -201,90 +199,6 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("keeps bottom anchoring through subpixel browser rounding", () => {
const scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4959.1708984375,
});
scrollTo.mockClear();
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4960.5,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4967,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -317,7 +231,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -327,6 +240,10 @@ describe("createWebStreamStrategy", () => {
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
@@ -335,109 +252,11 @@ describe("createWebStreamStrategy", () => {
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
HTMLElement.prototype.scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const renderInput = {
agentId: "agent",
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
}),
);
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
});
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
@@ -476,7 +295,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -584,7 +402,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -681,7 +498,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -780,7 +596,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,

View File

@@ -8,19 +8,11 @@ import React, {
useRef,
useState,
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
@@ -30,14 +22,9 @@ type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const HISTORY_START_THRESHOLD_PX = 96;
const historyStartSlotStyle: CSSProperties = {
display: "flex",
@@ -101,8 +88,7 @@ function getScrollContainerDistanceFromBottom(
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
@@ -119,7 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
isMobileBreakpoint,
} = props;
@@ -146,7 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -187,25 +171,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [rowVirtualizer]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const evaluateHistoryStart = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const bottomAnchorSettled =
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: scrollContainer.scrollTop,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && bottomAnchorSettled,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
});
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -264,9 +229,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollElementToBottom(scrollContainer, behavior);
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
evaluateHistoryStart();
},
[evaluateHistoryStart, onNearBottomChange],
[onNearBottomChange],
);
const scheduleStickToBottom = useCallback(() => {
@@ -331,20 +295,24 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
evaluateHistoryStart();
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
if (
historyStartReadyRef.current &&
hasOlderHistory &&
currentScrollTop <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
useEffect(() => {
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
});
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
};
}, [evaluateHistoryStart, props.agentId]);
}, [props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -400,12 +368,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -421,10 +384,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
return;
}
@@ -437,7 +398,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
}, [scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -447,14 +408,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
};
const handlePointerDown = () => {
@@ -477,14 +432,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -513,7 +462,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
}, [cancelPendingStickToBottom, handleDomScroll]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -595,20 +544,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!hasOlderHistory && !isLoadingOlderHistory) {
if (!isLoadingOlderHistory) {
return null;
}
return (
<div
style={historyStartSlotStyle}
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
<ActivityIndicator size="small" />
</div>
);
}, [hasOlderHistory, isLoadingOlderHistory]);
}, [isLoadingOlderHistory]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&

View File

@@ -72,7 +72,6 @@ export interface StreamRenderInput {
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;

View File

@@ -1,4 +1,3 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
forwardRef,
memo,
@@ -18,6 +17,7 @@ import {
Text,
Pressable,
Platform,
ActivityIndicator,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
@@ -247,7 +247,6 @@ export interface AgentStreamViewProps {
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => void;
};
}
@@ -389,11 +388,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
toast,
});
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
progressKey: historyPagination.progressKey,
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
@@ -878,7 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -1031,7 +1029,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -1141,7 +1138,6 @@ function historyPaginationPropsEqual(
return (
left?.hasOlder === right?.hasOlder &&
left?.isLoadingOlder === right?.isLoadingOlder &&
left?.progressKey === right?.progressKey &&
left?.onLoadOlder === right?.onLoadOlder
);
}
@@ -1198,7 +1194,7 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
@@ -1245,7 +1241,7 @@ function PermissionActionButton({
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} uniProps={colorMapping} />

View File

@@ -32,7 +32,6 @@ import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
@@ -67,7 +66,6 @@ import {
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -148,10 +146,9 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -552,7 +549,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<WorkspacePinShortcutHandler />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -912,7 +908,6 @@ function AppShell() {
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />

View File

@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const handledNavigationRef = useRef<string | null>(null);
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
}
if (!navigationKey || handledNavigationRef.current === navigationKey) {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
return;
}
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
if (!serverId || !agentId) {
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
return null;
}

View File

@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
@@ -110,9 +110,7 @@ function HostWorkspaceRouteContent() {
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -149,6 +147,7 @@ function HostWorkspaceRouteContent() {
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
@@ -172,7 +171,6 @@ function HostWorkspaceRouteContent() {
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
@@ -187,16 +185,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
@@ -242,7 +234,6 @@ function WorkspaceDeck({
selection={selection}
activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -255,13 +246,11 @@ function WorkspaceDeckEntry({
selection,
activeSelection,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -293,7 +282,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
recoveryAgentId={isActive ? recoveryAgentId : null}
/>
</RetainedPanel>
);

View File

@@ -58,6 +58,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
nova: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1"/>\n <circle cx="8" cy="8" r="4.5" fill="none" stroke="currentColor" stroke-width="0.3" opacity="0.5"/>\n <line x1="8" y1="8" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>\n <polygon points="14.5,14.5 12.3,13.5 13.5,12.3" fill="currentColor"/>\n <line x1="8" y1="8" x2="2.5" y2="2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>\n <polygon points="1.5,1.5 3.7,2.5 2.5,3.7" fill="currentColor"/>\n <circle cx="8" cy="8" r="1.3" fill="currentColor"/>\n <circle cx="8" cy="8" r="0.6" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/>\n</svg>\n',
opencode:
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="currentColor"/>\n</svg>\n',
"pi-acp":
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M1 1H11.7692V7.9999H8.17942V11.4999H4.58982V15H1V1ZM4.58982 4.50005V7.9999H8.17942V4.50005H4.58982Z" fill="currentColor"/>\n<path d="M11.7692 7.46154H15V15H11.7692V7.46154Z" fill="currentColor"/>\n</svg>\n',
poolside:
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M2.63653 5.50522L2.18735 5.28559C2.09852 5.46726 2.12787 5.68456 2.26172 5.83616C2.39558 5.98775 2.60757 6.04379 2.79884 5.97814L2.63653 5.50522ZM5.83578 6.94285L5.3732 7.13265L5.5645 7.59886L6.02919 7.40392L5.83578 6.94285ZM10.7441 9.44034L10.2614 9.57083L10.393 10.0573L10.8784 9.92198L10.7441 9.44034ZM2.5 8.24963C2.4998 7.97349 2.27577 7.74979 1.99963 7.75C1.72349 7.75021 1.4998 7.97423 1.5 8.25037L2.5 8.24963ZM13.4143 10.7751C12.0197 13.6273 8.57701 14.8089 5.72485 13.4143L5.28559 14.3126C8.63389 15.9498 12.6754 14.5627 14.3126 11.2144L13.4143 10.7751ZM3.08571 5.72485C4.48031 2.87269 7.92299 1.69111 10.7751 3.08571L11.2144 2.18735C7.86611 0.550151 3.82455 1.93728 2.18735 5.28559L3.08571 5.72485ZM10.7751 3.08571C13.6273 4.48031 14.8089 7.92299 13.4143 10.7751L14.3126 11.2144C15.9498 7.86611 14.5627 3.82455 11.2144 2.18735L10.7751 3.08571ZM2.79884 5.97814C3.02201 5.90155 3.54835 5.84926 4.09141 6.00587C4.61256 6.15616 5.10736 6.48476 5.3732 7.13265L6.29835 6.75304C5.89213 5.76303 5.11508 5.26032 4.3685 5.04503C3.64385 4.83605 2.9066 4.8839 2.47422 5.0323L2.79884 5.97814ZM6.02919 7.40392C6.25314 7.30998 6.55771 7.25841 6.85281 7.36034C7.13062 7.45631 7.49214 7.72014 7.78743 8.4398L8.71257 8.0602C8.3358 7.14195 7.79046 6.62626 7.17931 6.41515C6.58544 6.21001 6.01893 6.32381 5.64236 6.48177L6.02919 7.40392ZM6.26827 7.19375C6.80493 6.26869 8.18817 4.18684 11.1653 3.10654L10.8242 2.16652C7.53166 3.36127 5.99463 5.67263 5.40329 6.69195L6.26827 7.19375ZM14.3364 10.8325C14.1845 10.39 13.7564 9.79897 13.14 9.37995C12.504 8.94759 11.6302 8.67426 10.6099 8.9587L10.8784 9.92198C11.5634 9.73102 12.1368 9.90713 12.5779 10.207C13.0385 10.5201 13.3174 10.9441 13.3905 11.1571L14.3364 10.8325ZM11.2268 9.30984C11.1236 8.92833 10.8783 8.39265 10.3638 8.02453C9.82769 7.6409 9.08331 7.49864 8.11574 7.76836L8.38426 8.73164C9.1221 8.52595 9.53979 8.66454 9.78189 8.83777C10.0457 9.02651 10.1946 9.32371 10.2614 9.57083L11.2268 9.30984ZM11.216 9.60556C11.6225 8.44463 12.4244 5.47936 11.4712 2.48487L10.5183 2.78819C11.3726 5.47172 10.6545 8.18331 10.2722 9.27512L11.216 9.60556ZM7.80082 8.03037L5.05604 13.6438L5.9544 14.0831L8.69918 8.46963L7.80082 8.03037ZM5.72485 13.4143C3.68742 12.4181 2.50158 10.3763 2.5 8.24963L1.5 8.25037C1.50185 10.7441 2.89229 13.1424 5.28559 14.3126L5.72485 13.4143Z" fill="currentColor"/>\n</svg>\n',
qoder:

View File

@@ -6,7 +6,6 @@ import {
isRasterImageMimeType,
isRasterImagePath,
RASTER_IMAGE_FILE_EXTENSIONS,
resolveRasterImageMimeType,
} from "./file-types";
describe("attachment file types", () => {
@@ -38,28 +37,4 @@ describe("attachment file types", () => {
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
);
});
it("uses explicit raster MIME metadata before the filename", () => {
expect(
resolveRasterImageMimeType({ mimeType: "image/jpeg", path: "/tmp/screenshot.png" }),
).toBe("image/jpeg");
expect(
resolveRasterImageMimeType({
mimeType: "image/png; charset=binary",
path: "/tmp/screenshot.jpg",
}),
).toBe("image/png");
});
it("uses the filename only when MIME metadata is absent", () => {
expect(resolveRasterImageMimeType({ mimeType: "", path: "/tmp/screenshot.png" })).toBe(
"image/png",
);
expect(
resolveRasterImageMimeType({
mimeType: "application/octet-stream",
path: "/tmp/screenshot.png",
}),
).toBeNull();
});
});

View File

@@ -41,31 +41,18 @@ export function getRasterImageMimeTypeFromPath(path: string): string | null {
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
}
export function resolveRasterImageMimeType(input: {
mimeType?: string | null;
path?: string | null;
}): string | null {
const suppliedMimeType = input.mimeType?.trim();
if (suppliedMimeType) {
const normalizedMimeType = suppliedMimeType.split(";", 1)[0]?.trim().toLowerCase();
if (normalizedMimeType === "image/jpg") {
return "image/jpeg";
}
return normalizedMimeType && RASTER_IMAGE_MIME_TYPES.has(normalizedMimeType)
? normalizedMimeType
: null;
}
return input.path ? getRasterImageMimeTypeFromPath(input.path) : null;
}
export function isRasterImagePath(path: string): boolean {
return getRasterImageMimeTypeFromPath(path) !== null;
}
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
return resolveRasterImageMimeType({ mimeType }) !== null;
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized));
}
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
return resolveRasterImageMimeType({ mimeType: file.type, path: file.name }) !== null;
if (isRasterImageMimeType(file.type)) {
return true;
}
return file.type.trim().length === 0 && isRasterImagePath(file.name);
}

View File

@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106

View File

@@ -1,9 +0,0 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}

View File

@@ -1,54 +0,0 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}

View File

@@ -1,10 +0,0 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});

View File

@@ -1,55 +0,0 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});

View File

@@ -1,106 +0,0 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}

View File

@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,

View File

@@ -6,8 +6,11 @@ import {
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,

View File

@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";

View File

@@ -31,11 +31,6 @@ import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregate
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useProjects } from "@/hooks/use-projects";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -57,7 +52,6 @@ import type { CommandCenterContribution, CommandCenterIconProps } from "./contri
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
joinSubtitleParts,
moveActiveResultId,
preserveActiveResultId,
projectCommandCenterRows,
@@ -209,7 +203,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
const showAgentHost = useHosts().length > 1;
return useMemo(() => {
if (!open) return [];
@@ -219,11 +213,9 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
const title = workspace.title ?? workspace.name;
const subtitle = joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]);
const subtitle = workspace.currentBranch
? `${host.serverName} · ${workspace.currentBranch}`
: host.serverName;
const searchText = `${title} ${subtitle}`.toLowerCase();
allWorkspaces.push({
kind: "workspace",
@@ -259,11 +251,13 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
showHost ? agent.serverLabel : null,
const subtitle = [
showAgentHost ? agent.serverLabel : null,
location,
formatTimeAgo(agent.lastActivityAt),
]);
]
.filter((part): part is string => Boolean(part))
.join(" · ");
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
@@ -288,7 +282,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
},
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
];
}, [agents, open, projects, query, showHost, t]);
}, [agents, open, projects, query, showAgentHost, t]);
}
interface CommandCenterState {
@@ -377,6 +371,15 @@ function useCommandCenterState(): CommandCenterState {
return cancel;
}, [open]);
useEffect(() => {
if (!open || !isWeb) return;
const listener = (event: KeyboardEvent) => {
if (key(event.key)) event.preventDefault();
};
window.addEventListener("keydown", listener, true);
return () => window.removeEventListener("keydown", listener, true);
}, [key, open]);
return {
open,
query,
@@ -458,11 +461,7 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text
style={styles.subtitle}
numberOfLines={1}
testID="command-center-workspace-subtitle"
>
<Text style={styles.subtitle} numberOfLines={1}>
{result.subtitle}
</Text>
</View>
@@ -549,7 +548,6 @@ export function CommandCenter() {
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
@@ -615,19 +613,6 @@ export function CommandCenter() {
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
});
const backdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -673,29 +658,27 @@ export function CommandCenter() {
}
if (!state.open) return null;
return (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
</View>
</Modal>
</OverlayLayerProvider>
</View>
</Modal>
);
}

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import {
buildContributionSections,
joinSubtitleParts,
moveActiveResultId,
preserveActiveResultId,
projectCommandCenterRows,
@@ -104,35 +103,3 @@ describe("Command Center result projection", () => {
expect(projection.offsets[151]).toBe(32 + 150 * 56);
});
});
describe("joinSubtitleParts", () => {
it("joins all present parts with a middle dot", () => {
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
});
it("drops null and undefined parts", () => {
expect(joinSubtitleParts(["host", null, "master"])).toBe("host · master");
expect(joinSubtitleParts(["host", undefined, "master"])).toBe("host · master");
});
it("drops empty strings (Boolean parity — agents subtitle refactor guard)", () => {
expect(joinSubtitleParts(["", "paseo", "master"])).toBe("paseo · master");
});
it("returns an empty string when every part is null or empty", () => {
expect(joinSubtitleParts([null, undefined, ""])).toBe("");
});
it("returns a single part unchanged, with no separator", () => {
expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo");
});
it("builds the workspace subtitle in host · project · branch order", () => {
// Single-host: host gated away, project leads.
expect(joinSubtitleParts([null, "paseo", "master"])).toBe("paseo · master");
// Multi-host: host first, then project, then branch.
expect(joinSubtitleParts(["host", "paseo", "master"])).toBe("host · paseo · master");
// No branch: degrades to project (or host · project).
expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo");
});
});

View File

@@ -63,11 +63,6 @@ function matchesQuery(searchText: string, query: string): boolean {
return !normalized || searchText.includes(normalized);
}
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string {
return parts.filter((part): part is string => Boolean(part)).join(" · ");
}
function contributionSearchText(contribution: CommandCenterContribution): string {
const presentationText =
contribution.presentation.kind === "action"

View File

@@ -3,23 +3,17 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -59,8 +53,36 @@ export interface SheetHeader {
search?: SheetHeaderSearch;
}
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const top = escStack[escStack.length - 1];
if (!top) return;
event.stopPropagation();
event.preventDefault();
top();
}
function pushEscHandler(handler: EscHandler): () => void {
escStack.push(handler);
if (!escListenerAttached && typeof window !== "undefined") {
window.addEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = true;
}
return () => {
const index = escStack.lastIndexOf(handler);
if (index !== -1) escStack.splice(index, 1);
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
window.removeEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = false;
}
};
}
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
...StyleSheet.absoluteFillObject,
@@ -68,6 +90,7 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
alignItems: "center",
padding: theme.spacing[6],
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
desktopCard: {
@@ -180,14 +203,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4],
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
@@ -233,39 +248,6 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
return {
height: Math.max(
0,
containerHeight -
contentPosition -
handleHeight -
animatedKeyboardState.get().heightWithinContainer,
),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
@@ -470,16 +452,12 @@ export interface AdaptiveModalSheetProps {
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
footerContainerStyle?: StyleProp<ViewStyle>;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
contentContainerStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
}
export function AdaptiveModalSheet({
@@ -489,14 +467,11 @@ export function AdaptiveModalSheet({
onDismiss,
children,
footer,
footerContainerStyle,
snapPoints,
testID,
desktopMaxWidth,
scrollable = true,
presentation,
contentContainerStyle,
sizeContentToCurrentSnapPoint = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -515,37 +490,31 @@ export function AdaptiveModalSheet({
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
// Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web.
() => [
styles.bottomSheetContent,
[
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
[compactSafeAreaPadding.contentPaddingBottom],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const footerStyle = useMemo(
() => [
styles.footer,
footerContainerStyle,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
@@ -558,7 +527,6 @@ export function AdaptiveModalSheet({
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
@@ -587,31 +555,19 @@ export function AdaptiveModalSheet({
() => [
styles.desktopOverlay,
isWeb && {
zIndex: modalLayer,
opacity: isWebClosing ? 0 : 1,
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
transitionProperty: "opacity",
transitionTimingFunction: "ease",
},
],
[isWebClosing, modalLayer],
[isWebClosing],
);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
onClose();
return true;
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && !isMobile && visible,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
});
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
useEffect(() => {
if (visible) {
@@ -643,25 +599,6 @@ export function AdaptiveModalSheet({
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
const sheetContent = (
<>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
@@ -679,23 +616,31 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
sheetContent
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
<>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
@@ -703,10 +648,10 @@ export function AdaptiveModalSheet({
</ScrollView>
</View>
) : (
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</OverlayLayerProvider>
</>
);
const desktopContent = (
@@ -716,15 +661,7 @@ export function AdaptiveModalSheet({
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View
ref={setWebOverlayScope}
style={desktopCardStyle}
role="dialog"
aria-modal
tabIndex={-1}
>
{cardInner}
</View>
<View style={desktopCardStyle}>{cardInner}</View>
</View>
);

View File

@@ -11,15 +11,7 @@ import {
Search,
Server,
} from "lucide-react-native";
import {
createElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import {
Modal,
Pressable,
@@ -74,11 +66,6 @@ import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import {
useHosts,
useHostRuntimeClient,
@@ -782,20 +769,14 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
[activeIndex, handleBack, rows, submitActive],
);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!handleKey(event.key)) return false;
event.preventDefault();
return true;
},
[handleKey],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
});
useEffect(() => {
if (!isWeb || typeof window === "undefined") return;
const listener = (event: KeyboardEvent) => {
if (handleKey(event.key)) event.preventDefault();
};
window.addEventListener("keydown", listener, true);
return () => window.removeEventListener("keydown", listener, true);
}, [handleKey]);
const handleNativeKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
@@ -835,12 +816,11 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
? joinDirectoryPath(page.parentPath, page.name.trim())
: null;
const modal = (
return (
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
<View style={styles.overlay} testID="add-project-flow">
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
<View
ref={setWebOverlayScope}
style={styles.panel}
testID={`add-project-flow-page-${page.kind}`}
accessibilityLabel={`Add project: ${page.kind}`}
@@ -933,8 +913,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
</View>
</Modal>
);
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
}
const styles = StyleSheet.create((theme) => ({

View File

@@ -394,7 +394,6 @@ export function AgentList({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: true,
});
},
[isActionSheetVisible, onAgentSelect],

View File

@@ -9,7 +9,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
interface ArchivedAgentCalloutProps {
serverId: string;
@@ -22,7 +21,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
@@ -34,11 +32,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
} catch (error) {
setUnarchiveError(toErrorMessage(error));
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
@@ -47,23 +44,16 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
{unarchiveError ? (
<Text style={styles.errorText} testID="agent-unarchive-error">
{unarchiveError}
</Text>
) : null}
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>
</View>
@@ -107,16 +97,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: theme.spacing[6],
},
},
calloutStack: {
gap: theme.spacing[2],
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
})) as unknown as Record<string, object>;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";

View File

@@ -18,16 +18,17 @@ interface ContextWindowMeterProps {
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
@@ -73,7 +74,7 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -84,14 +85,12 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
@@ -104,7 +103,6 @@ export function ContextWindowMeter({
serverId,
provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -125,7 +123,7 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when

View File

@@ -1,6 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react";
import { View, Text, Pressable } from "react-native";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -99,7 +98,7 @@ export function DictationControls({
</Pressable>
{actionsDisabled ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.foreground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
@@ -222,7 +221,7 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (

View File

@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>

View File

@@ -1,8 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native";
@@ -70,7 +69,7 @@ export function DownloadToast() {
<View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<LoadingSpinner size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : null}
{activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />

View File

@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<ChangedFilesPane
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FilesPane
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;

View File

@@ -1,185 +0,0 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -1,5 +1,4 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}

View File

@@ -5,16 +5,12 @@ import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import {
getRasterImageMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
resolveRasterImageMimeType,
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -27,21 +23,14 @@ interface DesktopDragDropEvent {
}
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
const mimeType = resolveRasterImageMimeType({ path });
if (!mimeType) {
throw new Error(`Unsupported image type for '${path}'.`);
}
const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg";
return await persistAttachmentFromFileUri({ uri: path, mimeType });
}
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
const mimeType = resolveRasterImageMimeType({ mimeType: file.type, path: file.name });
if (!mimeType) {
throw new Error(`Unsupported image type for '${file.name}'.`);
}
return await persistAttachmentFromBlob({
blob: file,
mimeType,
mimeType: file.type || "image/jpeg",
fileName: file.name,
});
}
@@ -196,11 +185,7 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
@@ -212,11 +197,7 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -244,14 +225,6 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObj
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
ListRenderItemInfo,
Pressable,
@@ -11,30 +12,38 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -42,11 +51,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" },
];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -58,8 +62,6 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -68,8 +70,21 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
testID?: string;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
@@ -88,8 +103,6 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -98,17 +111,10 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -117,10 +123,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: treeRowPaddingLeft(depth) },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
@@ -131,46 +137,25 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<Pressable onPress={handlePress} style={pressableStyle}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
if (loading) return <ActivityIndicator size="small" />;
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
@@ -178,15 +163,40 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
@@ -196,7 +206,6 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -209,10 +218,14 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -222,6 +235,10 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -229,16 +246,12 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -333,14 +346,18 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -402,8 +419,6 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -411,7 +426,6 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -421,9 +435,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -556,7 +567,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (showInitialLoading) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View>
);
@@ -565,14 +576,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -583,7 +588,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -602,7 +606,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -775,6 +778,39 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -814,8 +850,6 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -823,10 +857,7 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -834,7 +865,6 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -845,8 +875,6 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -855,8 +883,6 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1035,6 +1061,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1084,8 +1111,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1105,6 +1133,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},

View File

@@ -7,7 +7,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="4 4 56 56" fill={color}>
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);

View File

@@ -7,7 +7,7 @@ interface PiIconProps {
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="100 100 600 600" fill={color}>
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}

View File

@@ -296,16 +296,6 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("normalizes HTML table cells and inline formatting into markdown", () => {
expect(
normalizeHtmlishMarkdown(
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
),
).toBe(
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
);
});
it("leaves complex code tags inert instead of parsing HTML", () => {
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
'<code onclick="evil()"><script>x</script></code>',

View File

@@ -30,15 +30,6 @@ const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
const VOID_HTML_TAGS = new Set(["br", "img"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
em: ["*", "*"],
i: ["*", "*"],
s: ["~~", "~~"],
strike: ["~~", "~~"],
strong: ["**", "**"],
};
interface ProtectedMarkdownRange {
start: number;
@@ -322,83 +313,36 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
output += renderHtmlTag(token, children);
if (token.name === "a") {
output += renderLinkToken(token, children);
index = closeIndex;
continue;
}
if (token.name === "sub") {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
output += `\`${renderInlineTokens(children)}\``;
index = closeIndex;
continue;
}
const rawTag = token.raw;
const tagName = token.name;
if (isHeadingTag(token)) {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
if (token.name === "sub" || isHeadingTagName(token.name)) {
return renderInlineTokens(children);
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
return `\`${renderInlineTokens(children)}\``;
}
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
if (wrapper) {
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
}
if (token.name === "table") {
return renderTableTokens(children);
}
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
const rows: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
if (!isOpenTag(tokens[index], "tr")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, "tr");
if (closeIndex === null) {
continue;
}
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
if (cells.length === 1) {
rows.push(`- ${cells[0]}`);
} else if (cells.length > 1) {
const label =
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
}
index = closeIndex;
}
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
}
function renderTableCells(tokens: HtmlToken[]): string[] {
const cells: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
continue;
}
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
if (cell) {
cells.push(cell);
}
index = closeIndex;
}
return cells;
}
function renderImageToken(token: HtmlTagToken): string {
const image = imageTokenToInlineImage(token, undefined);
if (!image) {

View File

@@ -1,9 +1,9 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
Image,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type LayoutChangeEvent,
StyleProp,
@@ -172,7 +172,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -866,9 +865,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
return (
<View style={frameStyle}>
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
@@ -1007,7 +1004,7 @@ function AssistantMarkdownImage({
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View style={stateFrameStyle}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="small" />
</View>
);
}
@@ -2247,7 +2244,7 @@ export const CompactionMarker = memo(function CompactionMarker({
<View style={compactionStylesheet.line} />
<View style={compactionStylesheet.label}>
{status === "loading" ? (
<LoadingSpinner size="small" color="#a1a1aa" />
<ActivityIndicator size="small" color="#a1a1aa" />
) : (
<Scissors size={12} color="#a1a1aa" />
)}
@@ -2660,9 +2657,7 @@ function computeShimmerMetrics(input: {
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
);
const isWebShimmer = input.isLoading && isWeb;
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
};
}
describe("model browser initial view", () => {
const codex = provider("codex", "Codex");
const pi = provider("pi", "Pi");
it("opens a sole provider directly", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
selectedProvider: "",
selectedModel: "",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the selected provider when its model is not a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the provider overview when the selected model is a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(["pi:pi-pro"]),
}),
).toEqual({ kind: "all" });
});
});

Some files were not shown because too many files have changed in this diff Show More