Compare commits

..

7 Commits

Author SHA1 Message Date
Mohamed Boudra
b589599a8f chore(release): cut 0.2.2 2026-07-26 00:17:17 +02:00
Mohamed Boudra
9821729c75 fix(claude): preserve parsed context variants 2026-07-25 23:23:33 +02:00
Mohamed Boudra
7e599db6fe fix(claude): preserve catalog on version probe failure 2026-07-25 22:47:17 +02:00
Mohamed Boudra
b3bfec027f test(cli): compose Claude catalog expectations 2026-07-25 22:22:38 +02:00
Mohamed Boudra
6610dbdffe test(cli): expect Claude context variants 2026-07-25 22:20:57 +02:00
Mohamed Boudra
a27a1a07dc fix(claude): add Fable and Sonnet context variants 2026-07-25 22:12:52 +02:00
Mohamed Boudra
dcaed8008b fix(claude): select the correct Opus 5 context window
Bare Opus 5 IDs resolve to 200K behind third-party gateways. Expose explicit context variants and require a Claude Code version that recognizes the model.
2026-07-25 22:04:02 +02:00
348 changed files with 2925 additions and 18374 deletions

View File

@@ -18,103 +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/**'
format:
runs-on: ubuntu-latest
env:
@@ -136,12 +39,6 @@ jobs:
run: npx oxfmt --check .
lint:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -166,12 +63,6 @@ jobs:
run: npm run lint
typecheck:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -198,12 +89,6 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -241,12 +126,6 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -255,9 +134,19 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- 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
with:
node-version: "22"
@@ -295,11 +184,7 @@ jobs:
retention-days: 7
- name: Build and smoke unpacked desktop app
if: >-
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
@@ -307,11 +192,7 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: >-
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
@@ -320,12 +201,6 @@ jobs:
retention-days: 7
app-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -350,12 +225,6 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -382,12 +251,6 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -446,12 +309,6 @@ jobs:
retention-days: 7
relay-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -473,12 +330,6 @@ jobs:
run: npm run test --workspace=@getpaseo/relay
cli-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -498,9 +349,6 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai

View File

@@ -1,34 +1,5 @@
# Changelog
## 0.2.3 - 2026-07-27
### 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))
### 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))
### 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))
## 0.2.2 - 2026-07-25
### Fixed

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,12 +21,11 @@ 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.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
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. An idle parent also stays resident while current
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
independently; collection does not cascade or change parentage.
`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

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

@@ -4,15 +4,9 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills or refreshes it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -456,8 +450,7 @@ Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |

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": {

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

@@ -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

View File

@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.

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

@@ -78,7 +78,7 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
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".

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

@@ -41,5 +41,5 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## 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

@@ -87,42 +87,14 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape or
a property inferred from message identity. The transaction registry holds every unresolved send and
records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists
solely so a later transport error cannot roll back a prompt already observed canonically.
The daemon's accepted response already waits for the correlated run start, but its response and the
directory update reach client state separately. An accepted transaction remains active until the
directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered
authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted
transaction, regardless of which arrived first; it cannot settle a fresh send.
Overlapping sends settle independently rather than collapsing to one newest pending message.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
identity to the existing row, and keeps the local presentation in its original timeline slot.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
payload while taking the canonical row's ordered position. If a live assistant head is the
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
## Relevant code

View File

@@ -1 +1 @@
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=

45
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.3",
"version": "0.2.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.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.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.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/server": "0.2.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.2",
"dependencies": {
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36502,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.3",
"version": "0.2.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.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.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.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37886,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.3",
"version": "0.2.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.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@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.2",
"@getpaseo/highlight": "0.2.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38649,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.3",
"version": "0.2.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.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

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { test, expect, type Page } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,21 +13,36 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -134,11 +149,7 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -156,11 +167,7 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -204,11 +211,7 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,

View File

@@ -1,685 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test as baseTest } from "./fixtures";
import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream";
import { gateNextAgentMessage } from "./helpers/agent-message-gate";
import {
attachImageFromMenu,
expectComposerDraft,
expectComposerEditable,
expectAttachmentPill,
expectComposerVisible,
fillComposerDraft,
sendDraftToQueue,
startRunningMockAgent,
} from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { selectModel } from "./helpers/app";
const IMAGE = {
name: "message-submission.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
interface MessageGeometry {
x: number;
y: number;
width: number;
height: number;
}
interface SubmissionScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
}
interface DraftCreateScenario {
workspaceId: string;
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}
interface RejectionScenario {
errorMessage: string;
}
interface UnrelatedRunningScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
}
const test = baseTest.extend<{
submissionScenario: SubmissionScenario;
draftCreateScenario: DraftCreateScenario;
rejectionScenario: RejectionScenario;
unrelatedRunningScenario: UnrelatedRunningScenario;
}>({
submissionScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
title: "Message submission regression",
model: "ten-second-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate });
await agent.cleanup();
},
draftCreateScenario: async ({ page }, provide, testInfo) => {
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const workspace = await seedWorkspace({
repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`,
});
await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay });
agentCreatedDelay.release();
await workspace.cleanup();
},
rejectionScenario: async ({ page }, provide, testInfo) => {
const errorMessage = "Requested mock prompt rejection";
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-rejection-${testInfo.workerIndex}-`,
title: "Message rejection regression",
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ errorMessage });
await agent.cleanup();
},
unrelatedRunningScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `unrelated-running-${testInfo.workerIndex}-`,
title: "Unrelated running transition",
model: "one-minute-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate, agent });
await agent.cleanup();
},
});
async function submitMessageWithImage(page: Page, prompt: string): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
const nextFrame = await composer.evaluate(
(composerElement, submittedPrompt) =>
new Promise<{
rowPresent: boolean;
workingPresent: boolean;
composerValue: string | null;
attachmentPresent: boolean;
}>((resolve) => {
requestAnimationFrame(() => {
const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement;
resolve({
rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)),
workingPresent: Boolean(
document.querySelector('[data-testid="turn-working-indicator"]'),
),
composerValue: composerInput.value,
attachmentPresent: Boolean(
document.querySelector('[data-testid="composer-image-attachment-pill"]'),
),
});
});
}),
prompt,
);
expect(nextFrame).toEqual({
rowPresent: true,
workingPresent: true,
composerValue: "",
attachmentPresent: false,
});
return page.getByTestId("user-message").filter({ hasText: prompt }).last();
}
async function submitImageOnlyMessage(page: Page): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").last();
await expect(userMessage).toBeVisible();
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
return userMessage;
}
async function expectPendingSubmission(page: Page, userMessage: Locator): Promise<void> {
await expect(userMessage).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached();
await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0");
await expect(userMessage).toHaveAttribute("aria-busy", "true");
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
const box = await userMessage.boundingBox();
if (!box) throw new Error("Submitted user message has no browser geometry");
const { offsetY } = await readScrollMetrics(page);
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
}
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await page.evaluate(() => {
const state = { active: true, sawMissing: false };
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionFooterContinuity = state;
const checkFrame = () => {
if (!state.active) return;
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
state.sawMissing = true;
}
requestAnimationFrame(checkFrame);
};
requestAnimationFrame(checkFrame);
});
return async () => {
const sawMissing = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionFooterContinuity as
| { active: boolean; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Working-footer continuity check was not started");
state.active = false;
delete windowState.__messageSubmissionFooterContinuity;
return state.sawMissing;
});
expect(sawMissing).toBe(false);
};
}
async function expectAcceptedSubmission(
page: Page,
userMessage: Locator,
submittedGeometry: MessageGeometry,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
}
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
}
async function expectRejectedSubmissionRestored(
page: Page,
input: { prompt: string; errorMessage: string },
): Promise<void> {
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
}
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function queueMessage(page: Page, prompt: string): Promise<void> {
await fillComposerDraft(page, prompt);
await sendDraftToQueue(page);
}
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount(
prompts.length,
);
for (const prompt of prompts) {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
}
async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise<void> {
await expectComposerDraft(page, prompt);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
async function expectInterruptedTurnOrderAfterReconnect(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`,
title: "Submission reconnect ordering",
model: "ten-second-stream",
});
const prompt = "Keep this prompt before its response.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted.");
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAgentStreamSuppressed(true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(promptRow).toBeVisible();
await gate.waitForServerMessage("send_agent_message_response");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setAgentStreamSuppressed(false);
gate.forceNextTimelineEpochReset();
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
title: "Submission missed running transition",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const userMessage = await submitImageOnlyMessage(page);
await gate.waitForHeldClientRequest();
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.releaseHeldClientRequest();
await gate.waitForServerMessage("send_agent_message_response");
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setServerMessageSuppressed("agent_status", false);
gate.setServerMessageSuppressed("agent_update", false);
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`,
title: "Submission acknowledgement before RPC",
model: "ten-second-stream",
});
const prompt = "Settle this provider-acknowledged submission.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.holdNextServerMessage("send_agent_message_response");
const userMessage = await submitMessageWithImage(page, prompt);
await gate.waitForHeldServerMessage();
await gate.waitForAgentStreamItem("user_message");
gate.releaseHeldServerMessage();
await gate.drop();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectLegacyAssistantStartsAfterInterruptedPrompt(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`,
title: "Legacy assistant interrupt boundary",
model: "ten-second-stream",
});
const prompt = "Start the replacement answer after this prompt.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer.");
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAssistantMessageIdsStripped(true);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(replacementAnswer).toBeVisible({ timeout: 30_000 });
await expectRenderedBefore(promptRow, replacementAnswer);
} finally {
gate.setAssistantMessageIdsStripped(false);
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
}
async function expectStaleCanonicalPagePreservesNewerLiveOutput(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`,
title: "Stale canonical page race",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call.");
await awaitToolCall(page, "read");
await page
.getByRole("button", { name: /stop|cancel/i })
.first()
.click();
await expectAgentIdle(page);
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.requestTimelineTail(agent.agentId);
await gate.waitForHeldServerMessage();
gate.truncateHeldTimelineAfterLast("tool_call");
expect(gate.getHeldTimelineLastItemType()).toBe("tool_call");
const nextPrompt = "Stream after the stale snapshot.";
await agent.client.sendAgentMessage(agent.agentId, nextPrompt);
const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt });
const liveAssistant = nextPromptRow.locator(
'xpath=following::*[@data-testid="assistant-message"][1]',
);
await expect(nextPromptRow).toBeVisible();
await expect(liveAssistant).toContainText("Cycle 1");
gate.releaseHeldServerMessage();
await expect(liveAssistant).toContainText("Cycle 1");
} finally {
await agent.cleanup();
}
}
async function expectCanonicalOrderWinsAcrossOverlappingClients(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
title: "Cross-client submission order",
model: "ten-second-stream",
});
const localPrompt = "Send this after the other client turn.";
const remotePrompt = "Commit this other client turn first.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const localRow = await submitMessageWithImage(page, localPrompt);
await gate.waitForHeldClientRequest();
await agent.client.sendAgentMessage(agent.agentId, remotePrompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt });
await expect(remoteRow).toBeVisible();
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.releaseHeldClientRequest();
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
await expect(localRow).toHaveAttribute("aria-busy", "false");
await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect
.poll(async () => {
const localElement = await localRow.elementHandle();
if (!localElement) return false;
return remoteRow.evaluate(
(remoteElement, localNode) =>
Boolean(
remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
localElement,
);
})
.toBe(true);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectRenderedBefore(first: Locator, second: Locator): Promise<void> {
const secondElement = await second.elementHandle();
if (!secondElement) throw new Error("Expected the second timeline item to be rendered");
expect(
await first.evaluate(
(firstElement, secondNode) =>
Boolean(
firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
secondElement,
),
).toBe(true);
}
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
}
async function expectCreatedAgentHandoff(
page: Page,
prompt: string,
userMessage: Locator,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 });
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
interface DraftCreatePendingSubmission {
prompt: string;
userMessage: Locator;
}
async function beginDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
): Promise<DraftCreatePendingSubmission> {
await openWorkspaceDraft(page, scenario.workspaceId);
await selectModel(page, "one-minute-stream");
const prompt = "Keep this row through create handoff.";
const userMessage = await submitMessageWithImage(page, prompt);
await scenario.agentCreatedDelay.waitForCreateRequest();
await scenario.agentCreatedDelay.waitForDelayedCreatedStatus();
await expectPendingSubmission(page, userMessage);
return { prompt, userMessage };
}
async function completeDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
pending: DraftCreatePendingSubmission,
): Promise<void> {
scenario.agentCreatedDelay.release();
await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage);
}
test.describe("Agent message submission", () => {
test("keeps the submitted row stable when the host accepts", async ({
page,
submissionScenario,
}) => {
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
await expectPendingSubmission(page, userMessage);
await submissionScenario.gate.waitForRequest();
const submittedGeometry = await readMessageGeometry(page, userMessage);
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
submissionScenario.gate.accept();
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
await finishFooterContinuityCheck();
});
test("keeps the submitted row stable through draft create handoff", async ({
page,
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
});
test("restores a rejected submission and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await retryRestoredSubmission(page, prompt);
});
test("restores overlapping queued sends when their connection fails", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `overlapping-queued-send-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep the agent running while messages queue.",
});
const prompts = ["Restore the first queued send.", "Restore the second queued send."];
try {
await queueMessage(page, prompts[0]);
await queueMessage(page, prompts[1]);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(1);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(2);
await gate.disconnect();
await expectQueuedSendFailuresRestored(page, prompts);
} finally {
await agent.cleanup();
}
});
test("does not accept a failed submission from an unrelated running turn", async ({
page,
unrelatedRunningScenario,
}) => {
const prompt = "Restore this unsent prompt.";
await submitMessageThatWillBeRejected(page, prompt);
await unrelatedRunningScenario.gate.waitForRequest();
await unrelatedRunningScenario.agent.client.sendAgentMessage(
unrelatedRunningScenario.agent.agentId,
"Start an unrelated turn.",
);
await expect(
page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }),
).toBeVisible();
await unrelatedRunningScenario.gate.disconnect();
await expectFailedSubmissionRestored(page, prompt);
});
test("keeps a submitted prompt before its response when canonical history arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo);
});
test("clears a provider acknowledgement that arrives before RPC acceptance", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo);
});
test("keeps an old-daemon replacement answer after its interrupted prompt", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo);
});
test("preserves newer live output when a stale canonical page arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo);
});
test("uses canonical order when another client turn overtakes a held submission", async ({
page,
}, testInfo) => {
await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo);
});
});

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

@@ -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

@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");

View File

@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectKey: string): Promise
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
`sidebar-project-new-workspace-row-${workspace.projectId}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,10 +195,9 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -207,7 +206,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectKey);
await removeProjectFromSidebar(page, workspace.projectId);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -221,24 +220,21 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
const readdedProjectId = readded.project?.projectId ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});

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,

View File

@@ -37,6 +37,17 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });

View File

@@ -1,86 +0,0 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
agentId: string;
}
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
typeof request.agentId !== "string"
) {
return null;
}
return {
type: "send_agent_message_request",
requestId: request.requestId,
agentId: request.agentId,
};
} catch {
return null;
}
}
export async function gateNextAgentMessage(page: Page) {
let serverSocket: WebSocketRoute | null = null;
let browserSocket: WebSocketRoute | null = null;
const heldMessages: Array<WebSocketMessage | null> = [];
const requests: SendAgentMessageRequest[] = [];
const requestWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
browserSocket = ws;
const server = ws.connectToServer();
serverSocket = server;
ws.onMessage((message) => {
const request = readSendRequest(message);
if (request) {
heldMessages.push(message);
requests.push(request);
for (const resolve of requestWaiters) resolve();
requestWaiters.clear();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
while (requests.length < count) {
await new Promise<void>((resolve) => requestWaiters.add(resolve));
}
return requests[count - 1];
};
return {
waitForRequest,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
throw new Error("No held send-agent-message request to accept");
}
serverSocket.send(heldMessage);
heldMessages[index] = null;
},
async disconnect(): Promise<void> {
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
},
};
}

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

@@ -16,20 +16,6 @@ interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
payload?: unknown;
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.message ?? envelope;
} catch {
return null;
}
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
@@ -52,111 +38,15 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
return null;
}
function stripAssistantMessageId(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
};
const event = (envelope.message?.payload ?? envelope.payload)?.event;
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
delete event.item.messageId;
return JSON.stringify(envelope);
}
function stripMessageSubmissionDisposition(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
return message;
}
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
delete payload.outOfBand;
return JSON.stringify(envelope);
}
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
if (!enabled || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
payload.epoch = `playwright-reset-${Date.now()}`;
payload.reset = true;
return JSON.stringify(envelope);
}
function readAgentStreamEventType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown } }).event;
return typeof event?.type === "string" ? event.type : null;
}
function readAgentStreamItemType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
.event;
return event?.type === "timeline" && typeof event.item?.type === "string"
? event.item.type
: null;
}
function shouldSuppressServerMessage(input: {
message: ClientRequest | null;
messageTypes: ReadonlySet<string>;
agentStreamEventTypes: ReadonlySet<string>;
suppressAgentStream: boolean;
}): boolean {
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
if (messageType && input.messageTypes.has(messageType)) return true;
if (input.suppressAgentStream && messageType === "agent_stream") return true;
const eventType = readAgentStreamEventType(input.message);
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
let stripSubmissionDisposition = false;
let heldClientRequestType: string | null = null;
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldClientRequest: (() => void) | null = null;
let heldServerMessageType: string | null = null;
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldServerMessage: (() => void) | null = null;
const suppressedServerMessageTypes = new Set<string>();
const suppressedAgentStreamEventTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
@@ -166,20 +56,9 @@ export async function installDaemonWebSocketGate(page: Page) {
activeSockets.add(ws);
const server = ws.connectToServer();
latestServer = server;
ws.onMessage((message) => {
if (!acceptingConnections) return;
if (reconnectWithFreshClient && typeof message === "string") {
const hello = readClientRequest(message);
if (hello?.type === "hello") {
const parsed = JSON.parse(message) as { clientId?: string };
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
reconnectWithFreshClient = false;
server.send(JSON.stringify(parsed));
return;
}
}
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
@@ -190,12 +69,6 @@ export async function installDaemonWebSocketGate(page: Page) {
directoryStarts.total[directory] += 1;
}
}
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
resolveHeldClientRequest = null;
return;
}
try {
server.send(message);
} catch {
@@ -205,55 +78,8 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readSessionMessage(message);
let outboundMessage = stripAssistantMessageId(
message,
stripAssistantMessageIds,
serverMessage?.type,
);
outboundMessage = stripMessageSubmissionDisposition(
outboundMessage,
stripSubmissionDisposition,
serverMessage?.type,
);
const shouldForceTimelineReset =
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
if (typeof serverMessage?.type === "string") {
serverMessageCounts.set(
serverMessage.type,
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
const agentStreamItemType = readAgentStreamItemType(serverMessage);
if (agentStreamItemType) {
agentStreamItemCounts.set(
agentStreamItemType,
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
if (serverMessage?.type === heldServerMessageType) {
heldServerMessage = { browser: ws, message: outboundMessage };
resolveHeldServerMessage?.();
resolveHeldServerMessage = null;
return;
}
if (
shouldSuppressServerMessage({
message: serverMessage,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
suppressAgentStream,
})
)
return;
try {
ws.send(outboundMessage);
ws.send(message);
} catch {
activeSockets.delete(ws);
}
@@ -274,125 +100,6 @@ export async function installDaemonWebSocketGate(page: Page) {
restore(): void {
acceptingConnections = true;
},
restoreFresh(): void {
reconnectWithFreshClient = true;
acceptingConnections = true;
},
holdNextClientRequest(type: string): void {
heldClientRequestType = type;
heldClientRequest = null;
},
waitForHeldClientRequest(): Promise<void> {
if (heldClientRequest) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldClientRequest = resolve;
});
},
releaseHeldClientRequest(): void {
if (!heldClientRequest) throw new Error("No held client request to release");
heldClientRequest.server.send(heldClientRequest.message);
heldClientRequest = null;
heldClientRequestType = null;
},
holdNextServerMessage(type: string): void {
heldServerMessageType = type;
heldServerMessage = null;
},
waitForHeldServerMessage(): Promise<void> {
if (heldServerMessage) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldServerMessage = resolve;
});
},
releaseHeldServerMessage(): void {
if (!heldServerMessage) throw new Error("No held server message to release");
heldServerMessage.browser.send(heldServerMessage.message);
heldServerMessage = null;
heldServerMessageType = null;
},
requestTimelineTail(agentId: string): void {
if (!latestServer) throw new Error("No daemon WebSocket is connected");
latestServer.send(
JSON.stringify({
type: "session",
message: {
type: "fetch_agent_timeline_request",
agentId,
requestId: `playwright-timeline-${Date.now()}`,
direction: "tail",
limit: 0,
projection: "projected",
},
}),
);
},
getHeldTimelineLastItemType(): string | null {
if (!heldServerMessage) throw new Error("No held server message to inspect");
const response = readSessionMessage(heldServerMessage.message);
const payload = response?.payload;
if (!payload || typeof payload !== "object") return null;
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) return null;
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
return typeof last?.item?.type === "string" ? last.item.type : null;
},
truncateHeldTimelineAfterLast(itemType: string): void {
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
throw new Error("No held text server message to truncate");
}
const envelope = JSON.parse(heldServerMessage.message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) throw new Error("Held message has no payload");
const entries = payload.entries;
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
const index = entries.findLastIndex(
(entry) =>
typeof entry === "object" &&
entry !== null &&
(entry as { item?: { type?: unknown } }).item?.type === itemType,
);
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
const lastSeq = retained.at(-1)?.seqEnd;
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
payload.entries = retained;
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
payload.hasNewer = false;
if (payload.window && typeof payload.window === "object") {
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
}
heldServerMessage.message = JSON.stringify(envelope);
},
setServerMessageSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedServerMessageTypes.add(type);
} else {
suppressedServerMessageTypes.delete(type);
}
},
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedAgentStreamEventTypes.add(type);
} else {
suppressedAgentStreamEventTypes.delete(type);
}
},
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setMessageSubmissionDispositionStripped(stripped: boolean): void {
stripSubmissionDisposition = stripped;
},
setAgentStreamSuppressed(suppressed: boolean): void {
suppressAgentStream = suppressed;
},
forceNextTimelineEpochReset(): void {
forceTimelineEpochReset = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
@@ -403,18 +110,5 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
getAgentStreamItemCount(type: string): number {
return agentStreamItemCounts.get(type) ?? 0;
},
async waitForServerMessage(type: string, count = 1): Promise<void> {
while ((serverMessageCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
};
}

View File

@@ -57,66 +57,6 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number },
): Promise<void> {
await addConnectedHostsAndReload(page, [input]);
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({

View File

@@ -9,7 +9,6 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -134,7 +133,6 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);

View File

@@ -46,13 +46,9 @@ function requireWorkspace(payload: WorkspacePayload) {
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Workspace ${workspace.id} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
@@ -181,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

@@ -7,8 +7,7 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}

View File

@@ -8,8 +8,6 @@ export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectKey?: string;
project?: { projectKey?: string };
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
@@ -27,7 +25,6 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
@@ -182,10 +179,8 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Host-local identity used by daemon project operations. */
/** Stable project identity the daemon groups workspaces under. */
projectId: string;
/** Opaque cross-host key used by grouped project UI and routes. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -213,10 +208,6 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${workspace.id} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -224,7 +215,6 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);

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

@@ -64,11 +64,6 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const projectKey =
createdWorkspace.workspace.projectKey ?? createdWorkspace.workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -80,7 +75,6 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);

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 });
@@ -165,7 +169,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +225,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});

View File

@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectKey,
projectKey: firstProject.projectId,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectKey,
projectKey: secondProject.projectId,
projectDisplayName: secondProject.projectDisplayName,
});

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,
}) => {
@@ -179,13 +164,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectKey,
projectKey: projectA.projectId,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -194,9 +179,7 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`,
);
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -205,7 +188,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectKey,
projectKey: projectB.projectId,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -228,8 +211,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -238,7 +221,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -251,7 +234,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectKey}`,
`new-workspace-project-picker-option-${gitProject.projectId}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();

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.projectKey,
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,52 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { submitMessage } from "./helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
test.describe("Codex out-of-band commands", () => {
test.setTimeout(300_000);
test("settles the submitted row when a goal command completes without a turn", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("settles the submitted row when an older daemon omits submission disposition", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
gate.setMessageSubmissionDispositionStripped(true);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
gate.restore();
await cleanupRewindFlow({ handle, cwd });
}
});
});

View File

@@ -1,10 +1,8 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -20,12 +18,8 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openAddProjectFlow(page);
await page.getByTestId("sidebar-add-project").click();
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -56,12 +50,8 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openAddProjectFlow(page);
await page.getByTestId("sidebar-add-project").click();
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();

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,7 +1,6 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
composerLocator,
expectComposerDraft,
@@ -24,152 +23,7 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
}
}
return false;
}, prompt),
)
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
}
async function waitForCurrentSubmissionExcludedFromCache(
page: Page,
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
const prompt = "Keep this cached submission pending.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-current-cache-e2e-",
title: "Current cache submission e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
gate.holdNextClientRequest("send_agent_message_request");
await submitMessage(page, prompt);
await gate.waitForHeldClientRequest();
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
await gate.drop();
await page.reload();
await expect(userMessage(page, prompt)).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
}
test.describe("Rewind sheet", () => {
test("does not restore a local-only submission from the display cache", async ({ page }) => {
await expectPendingSubmissionNotRestoredAfterReload(page);
});
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
const prompt = "Restore this rewind identity from the legacy cache.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-cache-upgrade-e2e-",
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
});
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";

View File

@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
timeout: 30_000,
});

View File

@@ -1,340 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addConnectedHostAndReload,
addConnectedHostsAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo, type TempDirectory } from "./helpers/workspace";
const SECONDARY_HOST_ID = "project-grouping-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SHARED_LEGACY_PROJECT_ID = "remote:github.com/paseo-e2e/grouped-project";
interface HostProject {
serverId: string;
projectId: string;
workspaceId: string;
}
interface CrossHostProject {
secondaryHost: IsolatedHostDaemon;
primary: HostProject;
secondary: HostProject;
}
interface ReconciledCrossHostProject extends CrossHostProject {
primaryHost: IsolatedHostDaemon;
}
async function createProject(
client: SeedDaemonClient,
repo: TempDirectory,
serverId: string,
): Promise<HostProject> {
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${serverId}`);
}
return {
serverId,
projectId: created.workspace.projectId,
workspaceId: created.workspace.id,
};
}
async function expectOneProjectContainsBothWorkspaces(
page: Page,
fixture: CrossHostProject,
): Promise<void> {
const primaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.primary.serverId}:${fixture.primary.workspaceId}`,
);
const secondaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.secondary.serverId}:${fixture.secondary.workspaceId}`,
);
await expect(primaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(secondaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="sidebar-project-row-"]')).toHaveCount(1);
}
async function openGroupedProjectSettings(page: Page): Promise<void> {
const projectRow = page.locator('[data-testid^="sidebar-project-row-"]').first();
const testId = await projectRow.getAttribute("data-testid");
if (!testId) throw new Error("Grouped project row has no test ID");
const projectKey = testId.slice("sidebar-project-row-".length);
await projectRow.hover();
await page.getByTestId(`sidebar-project-kebab-${projectKey}`).click();
await page.getByTestId(`sidebar-project-menu-open-settings-${projectKey}`).click();
await expect(page.getByTestId("host-picker")).toBeVisible({ timeout: 30_000 });
}
async function readPersistedProjectKey(host: IsolatedHostDaemon): Promise<unknown> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
return projects[0]?.projectKey;
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
delete project.projectKey;
}
await writeFile(projectsPath, JSON.stringify(projects));
const persisted = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
expect(persisted.every((project) => !("projectKey" in project))).toBe(true);
}
async function rewritePersistedProjectId(
host: IsolatedHostDaemon,
previousProjectId: string,
nextProjectId: string,
): Promise<void> {
const projectsDirectory = path.join(host.paseoHome, "projects");
const projectsPath = path.join(projectsDirectory, "projects.json");
const workspacesPath = path.join(projectsDirectory, "workspaces.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
const workspaces = JSON.parse(await readFile(workspacesPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
if (project.projectId === previousProjectId) project.projectId = nextProjectId;
}
for (const workspace of workspaces) {
if (workspace.projectId === previousProjectId) workspace.projectId = nextProjectId;
}
await Promise.all([
writeFile(projectsPath, JSON.stringify(projects)),
writeFile(workspacesPath, JSON.stringify(workspaces)),
]);
}
async function createReconciliationFixture(options?: { sharedLegacyProjectId?: string }): Promise<{
fixture: ReconciledCrossHostProject;
cleanup: () => Promise<void>;
}> {
const primaryHost = await startIsolatedHostDaemon(LEGACY_PRIMARY_HOST_ID);
const secondaryHost = await startIsolatedHostDaemon(LEGACY_SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
let primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
let secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
if (options?.sharedLegacyProjectId) {
await Promise.all([
rewritePersistedProjectId(primaryHost, primary.projectId, options.sharedLegacyProjectId),
rewritePersistedProjectId(
secondaryHost,
secondary.projectId,
options.sharedLegacyProjectId,
),
]);
primary = { ...primary, projectId: options.sharedLegacyProjectId };
secondary = { ...secondary, projectId: options.sharedLegacyProjectId };
}
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
return {
fixture: { primaryHost, secondaryHost, primary, secondary },
cleanup: async () => {
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
},
};
} catch (error) {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
throw error;
}
}
const test = base.extend<{
crossHostProject: CrossHostProject;
reconciledCrossHostProject: ReconciledCrossHostProject;
sharedLegacyIdCrossHostProject: ReconciledCrossHostProject;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: HostProject | null = null;
let secondary: HostProject | null = null;
try {
primary = await createProject(primaryClient, primaryRepo, getServerId());
secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await provide({ secondaryHost, primary, secondary });
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture();
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
sharedLegacyIdCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture({
sharedLegacyProjectId: SHARED_LEGACY_PROJECT_ID,
});
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
});
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
expect(crossHostProject.primary.projectId).not.toBe(crossHostProject.secondary.projectId);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: crossHostProject.secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: crossHostProject.secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: crossHostProject.secondaryHost.serverId,
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
});
test("groups persisted projects missing project keys after app boot", async ({
page,
reconciledCrossHostProject,
}) => {
expect(reconciledCrossHostProject.primary.projectId).not.toBe(
reconciledCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostsAndReload(page, [
{
serverId: reconciledCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: reconciledCrossHostProject.primaryHost.port,
},
{
serverId: reconciledCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: reconciledCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
await expect
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.primaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
await expect
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.secondaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
});
test("resets a rename draft when switching grouped-project hosts", async ({
page,
sharedLegacyIdCrossHostProject,
}) => {
expect(sharedLegacyIdCrossHostProject.primary.projectId).toBe(
sharedLegacyIdCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostsAndReload(page, [
{
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: sharedLegacyIdCrossHostProject.primaryHost.port,
},
{
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: sharedLegacyIdCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, sharedLegacyIdCrossHostProject);
await openGroupedProjectSettings(page);
await page.getByTestId("project-name-edit-button").click();
const nameInput = page.getByTestId("project-name-input");
await nameInput.fill("Draft from the first host");
await page.getByTestId("host-picker").click();
await page
.getByTestId(`host-picker-item-${sharedLegacyIdCrossHostProject.secondary.serverId}`)
.click();
await expect(nameInput).not.toBeVisible();
await page.getByTestId("project-name-edit-button").click();
await expect(page.getByTestId("project-name-input")).toHaveValue("");
});
});

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

@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");

View File

@@ -32,8 +32,6 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectKey?: string;
project?: { projectKey?: string };
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
@@ -463,18 +461,9 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledWorkspace = (await client.fetchWorkspaces()).entries.find(
(workspace) => workspace.id === seeded.workspaceA,
);
const reconciledProjectKey =
reconciledWorkspace?.projectKey ?? reconciledWorkspace?.project?.projectKey;
if (!reconciledProjectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: reconciledProjectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);

View File

@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectKey,
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({

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

@@ -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.2",
"private": true,
"main": "index.ts",
"scripts": {

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

@@ -9,6 +9,7 @@ import {
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
@@ -16,12 +17,8 @@ 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 type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
@@ -30,28 +27,12 @@ import {
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 ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const historyStartSlotStyle: ViewStyle = {
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
const HISTORY_START_THRESHOLD_PX = 96;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
@@ -91,7 +72,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
@@ -115,7 +95,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
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) {
@@ -144,23 +123,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) {
@@ -260,16 +222,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
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, clearPendingUserScrollEnd]);
useEffect(() => {
const keyboardEvents = [
@@ -348,7 +308,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const nearBottom = isScrollEventNearBottom(event);
onNearBottomChange(nearBottom);
evaluateHistoryStart();
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
if (
!isUserScrollActiveRef.current &&
@@ -366,15 +336,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent(() => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
// Defer drag end so momentum can take ownership, but capture the terminal
@@ -395,11 +359,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
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;
@@ -431,7 +390,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
previousViewportHeight,
viewportHeight,
});
evaluateHistoryStart();
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
@@ -447,13 +405,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);
@@ -493,20 +446,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.

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,
@@ -233,7 +231,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -317,7 +314,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -327,6 +323,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 +335,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 +378,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -584,7 +485,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -681,7 +581,6 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -780,7 +679,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;
@@ -33,11 +25,7 @@ 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",
@@ -119,7 +107,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
isMobileBreakpoint,
} = props;
@@ -146,7 +133,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 +173,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 +231,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 +297,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 +370,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 +386,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
return;
}
@@ -437,7 +400,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
}, [scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -447,14 +410,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 +434,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 +464,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 +546,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,
@@ -41,7 +41,6 @@ import {
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
@@ -240,7 +239,6 @@ export interface AgentStreamViewProps {
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
@@ -249,7 +247,6 @@ export interface AgentStreamViewProps {
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => void;
};
}
@@ -267,7 +264,6 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function buildChatHistoryAttachment(input: {
@@ -330,7 +326,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
@@ -345,10 +340,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
[pendingMessageSubmissions],
);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
@@ -397,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;
@@ -664,7 +654,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
@@ -673,14 +663,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
isPending={
item.clientMessageId !== undefined &&
pendingClientMessageIds.has(item.clientMessageId)
}
/>
);
},
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
[context.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -890,8 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter =
context.status === "running" || pendingMessageSubmissions.length > 0;
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -1044,7 +1029,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -1154,7 +1138,6 @@ function historyPaginationPropsEqual(
return (
left?.hasOlder === right?.hasOlder &&
left?.isLoadingOlder === right?.isLoadingOlder &&
left?.progressKey === right?.progressKey &&
left?.onLoadOlder === right?.onLoadOlder
);
}
@@ -1170,9 +1153,6 @@ function agentStreamViewPropsEqual(
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) {
reasons.push("pendingMessageSubmissions");
}
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {
@@ -1214,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);
@@ -1261,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";
@@ -552,7 +551,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<WorkspacePinShortcutHandler />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />

View File

@@ -1,11 +1,11 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteKey } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const projectKey = normalizeProjectSettingsRouteKey(params.projectKey);
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;

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

@@ -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

@@ -6,12 +6,7 @@ import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "r
import type { StyleProp, TextInputProps, ViewStyle } 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,
@@ -59,8 +54,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 +91,7 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
alignItems: "center",
padding: theme.spacing[6],
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
desktopCard: {
@@ -558,7 +582,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 +610,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) {
@@ -689,7 +700,7 @@ export function AdaptiveModalSheet({
}
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
<>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<View style={styles.desktopScrollContainer}>
@@ -706,7 +717,7 @@ export function AdaptiveModalSheet({
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</OverlayLayerProvider>
</>
);
const desktopContent = (
@@ -716,15 +727,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

@@ -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

@@ -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

@@ -5,9 +5,9 @@ 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";
@@ -27,21 +27,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,
});
}

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,7 +12,7 @@ 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";
@@ -23,7 +24,6 @@ import {
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} 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 { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
@@ -42,11 +42,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`;
@@ -168,9 +163,7 @@ function TreeRowItem({
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>
@@ -556,7 +549,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>
);

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,
@@ -132,7 +132,6 @@ interface UserMessageProps {
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
isPending?: boolean;
disableOuterSpacing?: boolean;
}
@@ -173,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) => ({
@@ -431,7 +429,6 @@ export const UserMessage = memo(function UserMessage({
client,
isFirstInGroup = true,
isLastInGroup = true,
isPending = false,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
@@ -443,7 +440,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -496,7 +493,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
<View style={containerStyle} testID="user-message">
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -540,15 +537,9 @@ export const UserMessage = memo(function UserMessage({
) : null}
</View>
{hasText ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
testID="user-message-trailing-row"
>
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
{formattedTimestamp}
</Text>
{capabilities && messageId ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}
@@ -874,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")}
@@ -1015,7 +1004,7 @@ function AssistantMarkdownImage({
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View style={stateFrameStyle}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="small" />
</View>
);
}
@@ -2255,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" />
)}

View File

@@ -34,7 +34,6 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { useCurrentOverlayLayer } from "@/lib/overlay-root";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
resolveInitialModelBrowserView,
@@ -54,36 +53,6 @@ const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
const ThemedStar = withUnistyles(Star);
function ProviderSettingsAction({
accessibilityLabel,
provider,
serverId,
}: {
accessibilityLabel: string;
provider: string;
serverId: string | null;
}) {
const overlayParentLayer = useCurrentOverlayLayer();
const handlePress = useCallback(() => {
if (!serverId) return;
useProviderSettingsStore.getState().open({ serverId, provider, overlayParentLayer });
}, [overlayParentLayer, provider, serverId]);
return (
<Pressable
onPress={handlePress}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={`selector-header-settings-${provider}`}
>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
);
}
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
null,
);
@@ -268,6 +237,11 @@ export function useModelBrowser({
setSearchQuery(value);
}, []);
const openProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
}, [serverId, view]);
const singleProviderView = providers.length === 1;
const header = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
@@ -280,13 +254,19 @@ export function useModelBrowser({
),
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: (
<ProviderSettingsAction
serverId={serverId}
provider={view.providerId}
<Pressable
onPress={openProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
/>
testID={`selector-header-settings-${view.providerId}`}
>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
),
search: {
onChange: handleSearchQueryChange,
@@ -299,6 +279,7 @@ export function useModelBrowser({
}, [
handleBackToAll,
handleSearchQueryChange,
openProviderSettings,
searchResetKey,
serverId,
singleProviderView,

View File

@@ -3,7 +3,13 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
Text,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
@@ -359,7 +365,7 @@ function DiagnosticSubSheet({
body = (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
@@ -498,7 +504,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View>
);

View File

@@ -1,13 +1,11 @@
import { useCallback } from "react";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { OverlayLayerProvider } from "@/lib/overlay-root";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
const visible = useProviderSettingsStore((state) => state.visible);
const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer);
const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => {
@@ -19,14 +17,12 @@ export function ProviderSettingsHost() {
}
return (
<OverlayLayerProvider layer={overlayParentLayer}>
<ProviderDiagnosticSheet
key={`${serverId}:${provider}`}
provider={provider}
serverId={serverId}
visible={visible}
onClose={handleClose}
/>
</OverlayLayerProvider>
<ProviderDiagnosticSheet
key={`${serverId}:${provider}`}
provider={provider}
serverId={serverId}
visible={visible}
onClose={handleClose}
/>
);
}

View File

@@ -1,6 +1,12 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native";
@@ -575,7 +581,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
@@ -593,7 +599,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />

View File

@@ -1,7 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
@@ -76,7 +75,7 @@ export function RealtimeVoiceOverlay({
style={stopButtonStyle}
>
{isSwitching ? (
<LoadingSpinner size="small" color={theme.colors.palette.white} />
<ActivityIndicator size="small" color={theme.colors.palette.white} />
) : (
<Square
size={theme.iconSize.lg}

View File

@@ -7,6 +7,7 @@ import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
@@ -35,6 +36,13 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()

View File

@@ -83,7 +83,6 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId,
projectKey: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,

View File

@@ -1,8 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
Pressable,
ActivityIndicator,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
@@ -48,11 +48,7 @@ import {
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -62,7 +58,6 @@ import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sec
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { resolveProjectSettingsRouteKey } from "@/projects/project-settings-target";
import {
buildNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -120,7 +115,6 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -137,8 +131,6 @@ import {
getIsElectron,
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -151,7 +143,7 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedPlus = withUnistyles(Plus);
@@ -265,7 +257,6 @@ interface ProjectHeaderRowProps {
chevron: "expand" | "collapse" | null;
onPress: () => void;
worktreeTarget: SidebarProjectHostTarget | null;
onlineServerIds: ReadonlySet<string>;
isProjectActive?: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
@@ -495,7 +486,6 @@ function ProjectRowTrailingActions({
project,
displayName,
worktreeTarget,
onlineServerIds,
isHovered,
isMobileBreakpoint,
isProjectActive,
@@ -506,7 +496,6 @@ function ProjectRowTrailingActions({
project: SidebarProjectEntry;
displayName: string;
worktreeTarget: SidebarProjectHostTarget | null;
onlineServerIds: ReadonlySet<string>;
isHovered: boolean;
isMobileBreakpoint: boolean;
isProjectActive: boolean;
@@ -515,8 +504,6 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -535,14 +522,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectSettingsKey={resolveProjectSettingsRouteKey({
...project,
hosts: project.hosts.map((host) => ({
...host,
isOnline: onlineServerIds.has(host.serverId),
})),
})}
projectPath={localProjectPath}
projectPath={project.iconWorkingDir}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -569,13 +549,11 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
projectSettingsKey,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectSettingsKey: string;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -583,10 +561,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectSettingsKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectSettingsKey));
}, [projectSettingsKey]);
const canOpenProjectSettings = projectSettingsKey.trim().length > 0;
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
@@ -631,10 +609,6 @@ function ProjectKebabMenu({
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectKey}`}
/>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
@@ -686,7 +660,6 @@ function WorkspaceRowRightGroup({
isPinned?: boolean;
onTogglePin?: () => void;
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
@@ -725,7 +698,6 @@ function WorkspaceRowRightGroup({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspacePath}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
@@ -774,7 +746,7 @@ function ProjectLeadingVisualStatus({
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -883,7 +855,7 @@ function NewWorktreeButton({
>
{({ hovered, pressed }) =>
loading ? (
<ThemedLoadingSpinner size={14} uniProps={foregroundMutedColorMapping} />
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedPlus
size={15}
@@ -929,7 +901,7 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: worktreeTarget.projectId ?? project.projectKey,
projectId: project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -984,7 +956,6 @@ function ProjectHeaderRow({
chevron,
onPress,
worktreeTarget,
onlineServerIds,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated: _onWorktreeCreated,
@@ -1010,7 +981,7 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: worktreeTarget.projectId ?? project.projectKey,
projectId: project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -1070,7 +1041,6 @@ function ProjectHeaderRow({
project={project}
displayName={displayName}
worktreeTarget={worktreeTarget}
onlineServerIds={onlineServerIds}
isHovered={isHovered}
isMobileBreakpoint={isMobileBreakpoint}
isProjectActive={isProjectActive}
@@ -1398,6 +1368,17 @@ function WorkspaceRowWithMenu({
},
});
useKeyboardActionHandler({
handlerId: `workspace-pin-${workspace.workspaceKey}`,
actions: ["workspace.pin"],
enabled: selected && canPin,
priority: 0,
handle: () => {
onTogglePin?.();
return true;
},
});
return (
<>
<WorkspaceRowInner
@@ -1625,7 +1606,6 @@ function ProjectBlock({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
@@ -1651,7 +1631,6 @@ function ProjectBlock({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -1667,9 +1646,8 @@ function ProjectBlock({
project,
collapsed,
supportsMultiplicityByServerId,
onlineServerIds,
}),
[collapsed, onlineServerIds, project, supportsMultiplicityByServerId],
[collapsed, project, supportsMultiplicityByServerId],
);
const active = isProjectSelectedByRoute({
@@ -1781,6 +1759,7 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})
@@ -1861,7 +1840,6 @@ function ProjectBlock({
worktreeTarget={
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
}
onlineServerIds={onlineServerIds}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
@@ -1895,7 +1873,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.onlineServerIds === next.onlineServerIds &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.parentGestureRef === next.parentGestureRef &&
@@ -1969,14 +1946,6 @@ export function SidebarWorkspaceList({
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const connectionStatusByServerId = useHostRuntimeConnectionStatuses(serverIds);
const onlineServerIds = useMemo(
() =>
new Set(
serverIds.filter((serverId) => connectionStatusByServerId.get(serverId) === "online"),
),
[connectionStatusByServerId, serverIds],
);
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
@@ -2013,7 +1982,6 @@ export function SidebarWorkspaceList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2085,7 +2053,6 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: Omit<
@@ -2096,7 +2063,6 @@ function ProjectModeList({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -2309,7 +2275,6 @@ function ProjectModeList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2323,7 +2288,6 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
onWorkspacePress,

View File

@@ -549,6 +549,17 @@ function StatusWorkspaceRowWithMenu({
},
});
useKeyboardActionHandler({
handlerId: `workspace-pin-${workspace.workspaceKey}`,
actions: ["workspace.pin"],
enabled: selected && canPin,
priority: 0,
handle: () => {
onTogglePin?.();
return true;
},
});
return (
<>
<StatusWorkspaceRowInner

View File

@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -59,7 +58,6 @@ interface SidebarWorkspaceMenuProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
openInFileManagerPath?: string | null;
}
export function SidebarWorkspaceMenu({
@@ -75,7 +73,6 @@ export function SidebarWorkspaceMenu({
archiveShortcutKeys,
isPinned,
onTogglePin,
openInFileManagerPath,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
@@ -140,10 +137,6 @@ export function SidebarWorkspaceMenu({
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={openInFileManagerPath}
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
/>
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}

View File

@@ -1,7 +1,13 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
import {
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleAlert,
@@ -48,7 +54,7 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
@@ -201,7 +207,7 @@ function WorkspaceStatusIndicator({
if (loading) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}

View File

@@ -4,6 +4,7 @@ const INTERACTIVE_TARGET_SELECTOR = [
"select",
"[role='button']",
"[role='link']",
"[contenteditable='true']",
"[data-paseo-pane-focus-exempt='true']",
].join(", ");

View File

@@ -95,7 +95,6 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
@@ -374,7 +373,6 @@ export function SplitContainer({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
@@ -594,7 +592,6 @@ export function SplitContainer({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
@@ -741,7 +738,6 @@ function SplitNodeView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
@@ -799,7 +795,6 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
@@ -849,7 +844,6 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
@@ -905,7 +899,6 @@ function SplitPaneView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
@@ -1051,7 +1044,6 @@ function SplitPaneView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}

View File

@@ -1,6 +1,11 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
@@ -831,7 +836,7 @@ export function TerminalPane({
{showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>

View File

@@ -1,4 +1,3 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
default as React,
useCallback,
@@ -9,7 +8,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -45,7 +44,7 @@ function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps)
if (loading) {
return (
<View>
<LoadingSpinner size="small" color={iconColor} />
<ActivityIndicator size="small" color={iconColor} />
</View>
);
}

View File

@@ -23,7 +23,6 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -63,12 +62,6 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -870,31 +863,45 @@ function isDesktopKey(key: string): key is DesktopKey {
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
}
function dispatchDesktopKey(
input: DesktopKeyHandlerInput,
key: DesktopKey,
event?: KeyboardEvent,
): boolean {
if (!input.isOpen) return false;
if (!IS_WEB && input.isMobile) return false;
function useWebKeyboardListener(
isOpen: boolean,
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void,
) {
useEffect(() => {
if (!IS_WEB || !isOpen) return;
const handler = (event: KeyboardEvent) => {
if (!isDesktopKey(event.key)) return;
handleDesktopKey(event.key, event);
};
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => {
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
}
function dispatchDesktopKey(input: DesktopKeyHandlerInput, key: DesktopKey, event?: KeyboardEvent) {
if (!input.isOpen) return;
if (!IS_WEB && input.isMobile) return;
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
handleDesktopArrowKey(input, key);
return true;
return;
}
if (key === "Enter") {
if (input.orderedVisibleOptions.length === 0) return false;
if (input.orderedVisibleOptions.length === 0) return;
event?.preventDefault();
handleDesktopEnterKey(input);
return true;
return;
}
if (key === "Escape") {
event?.preventDefault();
input.handleClose();
return true;
}
return false;
}
function resolveInitialActiveIndex(
@@ -1043,10 +1050,8 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
}
interface DesktopBodyProps {
overlayLayer: number;
isOpen: boolean;
handleClose: () => void;
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopFrameStyle: StyleProp<ViewStyle>;
@@ -1161,38 +1166,14 @@ function DesktopComboboxOptionsBody(props: {
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!isDesktopKey(event.key)) return false;
return handleDesktopKey(event.key, event);
},
[handleDesktopKey],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && props.isOpen,
layer: props.overlayLayer,
onKeyDown: handleWebOverlayKeyDown,
});
const setFloatingRef = useCallback(
(node: View | null) => {
props.refs.setFloating(node);
setWebOverlayScope(node);
},
[props.refs, setWebOverlayScope],
);
const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}>
<View
ref={props.refs.setOffsetParent}
collapsable={false}
style={[
styles.desktopOverlay,
isWeb ? styles.desktopOverlayWeb : null,
isWeb ? { zIndex: props.overlayLayer } : null,
]}
>
return (
<Modal
transparent
animationType="none"
visible={props.isOpen}
onRequestClose={props.handleClose}
>
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
<FloatingSurface
testID="combobox-desktop-container"
@@ -1200,11 +1181,8 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={styles.desktopContainer}
frameStyle={props.desktopFrameStyle}
ref={setFloatingRef}
ref={props.refs.setFloating}
collapsable={false}
role="dialog"
aria-modal
tabIndex={-1}
onLayout={props.handleDesktopContentLayout}
>
{props.hasChildren ? (
@@ -1237,21 +1215,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</FloatingSurface>
</View>
</OverlayLayerProvider>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
transparent
animationType="none"
visible={props.isOpen}
onRequestClose={props.handleClose}
>
{overlay}
</Modal>
);
}
@@ -1294,7 +1257,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
@@ -1500,7 +1462,7 @@ export function Combobox({
const handleDesktopKey = useCallback(
(key: DesktopKey, event?: KeyboardEvent) => {
return dispatchDesktopKey(
dispatchDesktopKey(
{
isOpen,
isMobile,
@@ -1517,6 +1479,7 @@ export function Combobox({
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
);
useWebKeyboardListener(isOpen, handleDesktopKey);
useDismissKeyboardOnOpen(isOpen, isMobile);
const handleIndicatorStyle = useMemo(
@@ -1593,10 +1556,8 @@ export function Combobox({
return (
<DesktopComboboxBody
overlayLayer={floatingLayer}
isOpen={isOpen}
handleClose={handleClose}
handleDesktopKey={handleDesktopKey}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopFrameStyle={desktopFrameStyle}
@@ -1746,10 +1707,6 @@ const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
flex: 1,
},
desktopOverlayWeb: {
...StyleSheet.absoluteFillObject,
pointerEvents: "auto" as const,
},
desktopBackdrop: {
position: "absolute",
top: 0,

View File

@@ -1,4 +1,3 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
type ComponentProps,
createContext,
@@ -15,6 +14,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
Modal,
Platform,
@@ -630,7 +630,7 @@ function resolveLeadingContent(input: {
successColor: string;
}): ReactElement | null {
if (input.isPending) {
return <LoadingSpinner size={16} color={input.pendingColor} />;
return <ActivityIndicator size={16} color={input.pendingColor} />;
}
if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />;

View File

@@ -1,4 +1,3 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
createContext,
useCallback,
@@ -15,6 +14,7 @@ import {
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Modal,
Pressable,
Text,
@@ -34,12 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -190,7 +185,6 @@ function renderDropdownSurface(input: {
content: ReactElement;
surfaceNativeID: string;
onExited: () => void;
scopeRef: (node: View | null) => void;
}): ReactElement {
const {
frameStyle,
@@ -201,7 +195,6 @@ function renderDropdownSurface(input: {
content,
surfaceNativeID,
onExited,
scopeRef,
} = input;
const body = scrollable ? (
@@ -219,9 +212,7 @@ function renderDropdownSurface(input: {
return (
<FloatingSurface
ref={scopeRef}
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
style={surfaceStyle}
@@ -458,7 +449,6 @@ export function DropdownMenuContent({
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const floatingLayer = useOverlayLayer("floating");
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
@@ -512,21 +502,16 @@ export function DropdownMenuContent({
setOpen(false);
}, [setOpen]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
useEffect(() => {
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
handleClose();
return true;
},
[handleClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && modalVisible,
layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown,
});
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleClose, modalVisible]);
// Measure trigger when opening
useEffect(() => {
@@ -646,36 +631,27 @@ export function DropdownMenuContent({
);
const overlay = (
<OverlayLayerProvider layer={floatingLayer}>
<View
style={[
styles.overlay,
isWeb ? styles.overlayWeb : null,
isWeb ? { zIndex: floatingLayer } : null,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
scopeRef: setWebOverlayScope,
onExited: () => setModalVisible(false),
})
: null}
</View>
</OverlayLayerProvider>
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
);
if (isWeb && typeof document !== "undefined") {
@@ -741,7 +717,7 @@ function resolveDropdownItemLeadingContent(input: {
}): ReactElement | null {
const { isPending, isSuccess, leading, theme } = input;
if (isPending) {
return <LoadingSpinner size={16} color={theme.colors.foregroundMuted} />;
return <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
}
if (isSuccess) {
return <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
@@ -929,6 +905,7 @@ const styles = StyleSheet.create((theme) => ({
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: {

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