Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 979326c04f Install local speech runtime on demand
The daemon no longer bundles sherpa-onnx, sherpa-onnx-node, or
onnxruntime-node. On first boot they are downloaded into
$PASEO_HOME/runtime/local-speech alongside the existing voice models,
non-blocking. Users who don't enable local voice never pay the size
cost.

The wire protocol is unchanged. server_info.capabilities.voice.*
already gates voice in the client; readiness now reports
runtime_install_in_progress / runtime_missing / runtime_install_failed
on the same channel as the existing model_download_* states.

The native runtime lives under packages/server/src/server/speech/
providers/local/runtime and exposes a small surface: configure the
home once at speech-service init, ensureInstalled, loadPackage,
getStatus, plus a typed error. Provider configs do not know about
the install path.
2026-05-21 10:44:50 +00:00
873 changed files with 26503 additions and 45144 deletions
+34 -44
View File
@@ -69,18 +69,18 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Typecheck all packages
run: npm run typecheck
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
strategy:
fail-fast: false
@@ -107,8 +107,11 @@ jobs:
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
@@ -134,8 +137,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
@@ -156,37 +165,12 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build app dependencies
run: npm run build:app-deps
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
sdk-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
runs-on: ubuntu-latest
steps:
@@ -203,11 +187,14 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build app dependencies
run: npm run build:app-deps
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build server stack
run: npm run build:server
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
@@ -241,7 +228,7 @@ jobs:
run: npm ci
- name: Build relay
run: npm run build:relay
run: npm run build --workspace=@getpaseo/relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
@@ -267,6 +254,9 @@ jobs:
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app
-1
View File
@@ -5,7 +5,6 @@ on:
branches: [main]
paths:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "package.json"
- "package-lock.json"
-24
View File
@@ -183,14 +183,6 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-macos-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -281,14 +273,6 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -376,14 +360,6 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
-2
View File
@@ -10,8 +10,6 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
-101
View File
@@ -1,106 +1,5 @@
# Changelog
## 0.1.84 - 2026-05-28
### Added
- **Auto-accept tool calls for OpenCode agents**
### Improved
- Copy an OpenCode resume command to continue the session outside Paseo
- Model selector lists every enabled provider, with a Retry button when one fails to load
- Provider settings are easier to search and manage
- Other agents connecting to Paseo via MCP see the same providers, models, and modes as the app ([#1198](https://github.com/getpaseo/paseo/pull/1198))
- OpenCode Edit tool calls render as inline diffs
- Typing a slash command shows the best match first
- Daemon starts faster on workspaces with many git folders
- Markdown lists have tighter spacing
- Less jank when streaming agent responses
- User message footer controls align with the rest of the chat
- Agent mode controls use a cleaner monochrome treatment
- Compact layouts move the context ring to the footer right edge
### Fixed
- Allow selecting text in the chat on mobile ([#1153](https://github.com/getpaseo/paseo/pull/1153) by [@muzhi1991](https://github.com/muzhi1991))
- Submitting a Pi question no longer looks like a second prompt opened ([#1188](https://github.com/getpaseo/paseo/pull/1188) by [@yuruiz](https://github.com/yuruiz))
- Daemon memory leak from unbounded workspace git caches ([#1200](https://github.com/getpaseo/paseo/pull/1200))
- Provider diagnostics include the command override binary path ([#1191](https://github.com/getpaseo/paseo/pull/1191))
- OpenCode MCP servers connect correctly when the daemon binds to wildcard addresses
- Tool calls from MCP servers that return non-spec output no longer fail validation
## 0.1.83 - 2026-05-26
### Fixed
- Creating an agent via MCP now waits for it to actually start, so failures surface as a clear create error
- Scheduling an agent via MCP no longer rejects blank cadence placeholders
- Draft messages show the agent mode chip again on models without thinking options
## 0.1.82 - 2026-05-26
### Added
- **Rewind chat or files from any user message** ([#1154](https://github.com/getpaseo/paseo/pull/1154))
- **See the cumulative cost of an agent session** ([#1163](https://github.com/getpaseo/paseo/pull/1163))
- **Drop files onto the terminal to insert their paths** ([#1173](https://github.com/getpaseo/paseo/pull/1173))
- **Tap a file path in the terminal to open it in the workspace preview** ([#1174](https://github.com/getpaseo/paseo/pull/1174))
- **Approve OpenCode permissions for the whole session** ([#1168](https://github.com/getpaseo/paseo/pull/1168))
- **Workspace scripts now appear on the mobile header** ([#1093](https://github.com/getpaseo/paseo/pull/1093) by [@ayhanmalkoc](https://github.com/ayhanmalkoc))
- Devin CLI in the ACP provider catalog (by [@Alcimerio](https://github.com/Alcimerio))
- OpenCode agents show their mode colors
### Improved
- Mobile terminal keyboard hides when you open a sidebar
- Tool activity for read, write, and OpenCode tools renders more consistently ([#1171](https://github.com/getpaseo/paseo/pull/1171))
- Compact workspace header actions are tidier
- Settings latency readouts are easier to scan ([#1170](https://github.com/getpaseo/paseo/pull/1170))
- Pull request merge is available as soon as GitHub reports the PR is ready ([#1172](https://github.com/getpaseo/paseo/pull/1172))
### Fixed
- Mobile slash command autocomplete no longer flickers or mis-layers
- Interrupting an OpenCode agent returns it to idle instead of showing an error ([#1169](https://github.com/getpaseo/paseo/pull/1169))
- Provider model selection per workspace is honored ([#1167](https://github.com/getpaseo/paseo/pull/1167))
- Draft composer keeps the permission mode you selected ([#1175](https://github.com/getpaseo/paseo/pull/1175))
- Terminal color queries no longer return malformed replies
- File links in chat no longer crash when a message contains a bare '%' (by [@Elliotwu-7](https://github.com/Elliotwu-7))
## 0.1.81 - 2026-05-24
### Added
- **Paseo can now be installed as a web app from supported browsers** ([#1144](https://github.com/getpaseo/paseo/pull/1144))
- **Pi extension dialogs now appear as Paseo permission prompts** ([#1134](https://github.com/getpaseo/paseo/pull/1134) by [@yuruiz](https://github.com/yuruiz))
- Added community links and a home button to the sidebar
### Improved
- **Mobile terminals load faster and restore existing output more smoothly** ([#1147](https://github.com/getpaseo/paseo/pull/1147))
- Copying assistant messages preserves formatting
- Agent metadata fallback failures now log each provider attempt for easier debugging
### Fixed
- Android: slash command suggestions stay interactive when opened from the composer
- macOS: Alt+letter shortcuts work again
- Terminal panes no longer flicker during resize
- OpenCode MCP servers are injected once instead of being connected twice
- Import session no longer shows empty sessions
- Worktree archive status no longer reports false unpushed commits ([#1158](https://github.com/getpaseo/paseo/pull/1158))
- The `/exit`, `/quit`, and `/q` slash command aliases now show as one row
- Shortcut chord badges are readable in light mode
- Segmented controls show their track under every segment
- Sheet header search text is readable in dark mode
## 0.1.80 - 2026-05-21
### Fixed
- Opening dropdown menus no longer crashes on mobile
## 0.1.79 - 2026-05-21
### Added
+2 -4
View File
@@ -32,7 +32,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
@@ -71,9 +70,8 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
+2 -5
View File
@@ -17,9 +17,6 @@
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
@@ -134,8 +131,8 @@ npm run dev:app
npm run dev:desktop
npm run dev:website
# build the server stack
npm run build:server
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
+1 -1
View File
@@ -56,7 +56,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt
+4 -16
View File
@@ -68,20 +68,9 @@ All paths are under `packages/server/src/`.
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
### `packages/app` — Mobile + web client (Expo)
@@ -90,7 +79,6 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
@@ -137,7 +125,7 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
**Handshake:**
+23 -58
View File
@@ -24,7 +24,6 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
@@ -60,7 +59,29 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
---
@@ -185,62 +206,6 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
---
## Codex with a custom OpenAI-compatible endpoint
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"description": "Codex via custom OpenAI-compatible endpoint",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
### What Paseo wires up
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider = "my-codex"
[model_providers.my-codex]
name = "My Codex"
base_url = "https://custom-relay.example.com/v1"
wire_api = "responses"
env_key = "OPENAI_API_KEY"
requires_openai_auth = false
```
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
### Notes
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
+24 -8
View File
@@ -171,11 +171,27 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
---
## 3. Local Speech Runtime
**Path:** `$PASEO_HOME/runtime/local-speech/node_modules/`
Native local voice packages are installed on demand at daemon bootstrap, not bundled with the daemon package. The runtime installer downloads npm tarballs into `$PASEO_HOME/runtime/local-speech/.downloads`, extracts them into a temporary directory, verifies required files, then atomically moves each package into `node_modules`.
Installed packages:
- `sherpa-onnx`
- `sherpa-onnx-node`
- `sherpa-onnx-<platform>-<arch>` for the current host
- `onnxruntime-common`
- `onnxruntime-node`
The daemon starts without waiting for these packages. While they are missing, `server_info.capabilities.voice.*` reports local voice unavailable through the existing capability fields. Once the runtime and required local speech models are present, the next speech runtime reconcile makes local voice available without restarting the daemon.
---
## 3. Schedule
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -223,7 +239,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
---
## 4. Chat
## 5. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
@@ -260,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -349,7 +365,7 @@ Single file containing an array of all loop records. Writes are direct (not atom
---
## 6. Project Registry
## 7. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -372,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -391,7 +407,7 @@ Array of workspace records. A workspace is a specific working directory within a
---
## 8. Push Token Store
## 9. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -405,7 +421,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 10. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
+8 -27
View File
@@ -109,28 +109,23 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
}
```
## Built workspace packages
## Build sync gotchas
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
```bash
npm run build:client # protocol -> client
npm run build:server-deps # highlight -> relay -> protocol -> client
npm run build:server # server-deps -> server -> cli
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
npm run build:daemon
```
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
This rebuilds, in order, `@getpaseo/highlight` → `@getpaseo/relay` → `@getpaseo/server` → `@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
## CLI reference
@@ -192,20 +187,6 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash
@@ -1,211 +0,0 @@
# Git Snapshot Startup Reshaping - 2026-05-27
## What changed
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
```text
daemon startup / workspace subscription
-> WorkspaceGitService.refreshSnapshot(cwd)
-> getCheckoutSnapshotFacts(cwd)
-> getCheckoutStatus(cwd, { facts })
-> getCheckoutShortstat(cwd, { facts })
-> getPullRequestStatus(cwd, github, ..., { facts })
-> WorkspaceGitRuntimeSnapshot
-> session workspace descriptor githubRuntime.pullRequest
-> app useSidebarWorkspacesList()
-> SidebarWorkspaceEntry.prHint
-> Sidebar row badge + hover card checks
```
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
## Shared Git Facts
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
- worktree root: `rev-parse --show-toplevel`
- current branch: `rev-parse --abbrev-ref HEAD`
- origin remote URL
- Paseo worktree ownership and stored base ref
- resolved base ref and best comparison base
- main repo root
- branch remote/merge config
- tracked origin branch
- pull request lookup target for fork/PR worktrees
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
## Current Data Flow
```text
Workspace subscription / fetch_workspaces
-> session workspace registry
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
-> refresh queue/throttle/dedupe per normalized cwd
-> refreshGitSnapshot()
-> getCheckoutSnapshotFacts()
-> getCheckoutStatus({ facts })
-> getCheckoutShortstat({ facts })
-> refreshGitHubSnapshot()
-> getPullRequestStatus({ facts })
-> cached WorkspaceGitRuntimeSnapshot
-> WorkspaceDescriptorPayload.gitRuntime
-> WorkspaceDescriptorPayload.githubRuntime
-> app session store
-> useSidebarWorkspacesList()
-> diffStat from descriptor
-> prHint from descriptor.githubRuntime.pullRequest
```
## Startup Benchmark
Added deterministic real-home benchmark:
`packages/server/scripts/benchmark-startup-git-real-home.ts`
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
The frozen home used for the comparison contained 22 workspaces.
### Before/After
| run | code shape | client shape | git commands | failures | elapsed |
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
### Baseline: before change + legacy PR fanout
```json
{
"scenario": "legacyPrFanout",
"workspaceCount": 22,
"elapsedMs": 39039,
"git": {
"total": 529,
"failed": 20,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
{ "key": "rev-parse --git-common-dir", "count": 58 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
{ "key": "rev-parse --git-dir", "count": 36 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
{ "key": "config --get remote.origin.url", "count": 32 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 14 },
{ "key": "status --porcelain", "count": 14 }
]
},
"process": {
"cpuUserMs": 2009,
"cpuSystemMs": 2428,
"rssDeltaMb": -1.5,
"heapUsedDeltaMb": 16.9
}
}
```
### After: after change + snapshot-only sidebar
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31273,
"git": {
"total": 372,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "config --get remote.origin.url", "count": 35 },
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
{ "key": "rev-parse --git-common-dir", "count": 31 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "status --porcelain", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 17 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
{ "key": "rev-list --count main..origin/main", "count": 7 }
]
},
"process": {
"cpuUserMs": 1871,
"cpuSystemMs": 2152,
"rssDeltaMb": 4.4,
"heapUsedDeltaMb": 8.8
}
}
```
### After 2: shared service-level facts
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31334,
"git": {
"total": 308,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
{ "key": "rev-parse --git-common-dir", "count": 26 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "status --porcelain", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "config --get remote.origin.url", "count": 13 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
{ "key": "rev-parse --show-toplevel", "count": 13 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
{ "key": "fetch origin --prune", "count": 5 }
]
},
"process": {
"cpuUserMs": 1817,
"cpuSystemMs": 1869,
"rssDeltaMb": 16.7,
"heapUsedDeltaMb": 10.4
}
}
```
## Snapshot Equivalence Guard
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
- `rev-parse --show-toplevel`
- `rev-parse --abbrev-ref HEAD`
Test:
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
## Remaining Waste Visible In Baseline
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
- base ref existence checks still repeat as `show-ref` probes.
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
- repo common-dir lookup is lower, but still above the apparent git workspace count.
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.
@@ -1,389 +0,0 @@
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
## Answer
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
- `/Users/moboudra`
- `/Users/moboudra/dev/paseo`
- `/Users/moboudra/dev/blankpage/editor`
3. Each OpenCode snapshot runs two OpenCode SDK calls:
- `GET /provider?directory=...` through `client.provider.list()`
- `GET /agent?directory=...` through `client.app.agents()`
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
So the root cause is:
```text
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
```
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
## Follow-up: Normal Copied-Home Startup Check
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
```text
fetchWorkspaces
fetchAgents
getProvidersSnapshot(home scope)
getProvidersSnapshot(first workspace scope)
```
That run did not reproduce the 30s OpenCode timeout.
```text
home scope:
OpenCode ready at ~8s
availability: 1.6s
fetch total: 5.2s
first workspace scope:
OpenCode ready at ~26s
availability: 2.0s
fetch total: 15.4s
```
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
## Reproduction Used
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
Relevant scripts:
- `scripts/dev.sh`
- `scripts/dev-daemon.sh`
- `scripts/dev-home.sh`
`dev-home.sh` only seeds this metadata into the dev home:
```text
agents/**/*.json
projects/**/*.json
config.json
```
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
I ran a separate daemon, not the main daemon:
```text
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
PASEO_LISTEN=127.0.0.1:51116
PASEO_LOG_LEVEL=trace
```
Startup facts:
```text
18:13:39.552 Agent storage initialized: 712ms
18:13:39.559 Workspace registries bootstrapped: 719ms
18:13:39.961 Agent registry loaded: 4851 records
18:13:39.972 Server listening: http://127.0.0.1:51116
```
The probe then connected four client sessions and requested:
- workspaces
- active agents
- provider snapshots for home, paseo, and blankpage/editor
Client-visible result:
```text
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
OpenCode app.agents timed out after 10s
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
Timed out refreshing OpenCode after 30000ms
18:14:41.688 /Users/moboudra opencode error:
Timed out refreshing OpenCode after 30000ms
```
## Exact OpenCode Timeline
OpenCode snapshot requests began at `18:14:10`.
Availability checks:
```text
18:14:10.780 opencode availability start for /Users/moboudra
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
18:14:11.363 paseo availability complete: 576ms
18:14:11.376 home availability complete: 597ms
18:14:11.391 blankpage availability complete: 591ms
```
OpenCode server acquisition:
```text
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
18:14:19.926 OpenCode server listening after 8562ms
```
Six SDK calls were then issued:
```text
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /provider directory=/Users/moboudra
18:14:19.931 GET /agent directory=/Users/moboudra
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
```
Why six:
| Cwd | Why that scope exists | Model call | Mode call |
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
Headers arrived before the 30s timeout:
| Call | Cwd | Headers after request |
| ----------- | ------------------------------- | --------------------- |
| `/provider` | `/Users/moboudra` | 6.462s |
| `/agent` | `/Users/moboudra` | 6.681s |
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
But body consumption and completion lagged:
```text
18:14:29.380 /agent home complete, total app.agents duration 9450ms
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
18:14:30.263 /agent blankpage timed out at 10s
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
18:14:41.687 paseo snapshot outer 30s timeout fires
18:14:41.688 home snapshot outer 30s timeout fires
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
```
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
## Why Settings Refresh Works
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
Results:
```text
home refresh:
total: 2165ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/paseo refresh:
total: 1675ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/blankpage/editor refresh:
total: 1794ms
status: ready
models: 409
modes: 5
```
Trace details for the manual-style refresh:
```text
OpenCode server acquisition: 708ms-1291ms
/agent completion: 433ms-592ms after request start
/provider completion: 524ms-618ms after request start
```
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
## Minimal OpenCode-Only Repros
### OpenCode Only, No Daemon, No Artificial Load
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
```text
GET /provider?directory=/Users/moboudra
GET /agent?directory=/Users/moboudra
GET /provider?directory=/Users/moboudra/dev/paseo
GET /agent?directory=/Users/moboudra/dev/paseo
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
```
Three runs:
| Run | `opencode serve` ready | All six calls complete |
| --- | ---------------------- | ---------------------- |
| 1 | 1376ms | 1295ms |
| 2 | 906ms | 1050ms |
| 3 | 939ms | 898ms |
Slowest individual call in those runs:
```text
/provider /Users/moboudra/dev/paseo: 1270ms total
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
```
So six concurrent OpenCode calls alone are not the bug.
### OpenCode Only Plus External Git Storm, No Daemon
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
Result:
```text
opencode serve ready: 15479ms
all six OpenCode calls complete: 15176ms after server ready
combined cold-start + calls: about 30655ms
```
Individual calls under the external git storm:
| Call | Cwd | Total |
| ----------- | -------------------------------------- | ------: |
| `/provider` | `/Users/moboudra` | 10684ms |
| `/agent` | `/Users/moboudra` | 10767ms |
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
## Why It Does Not Retry
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
- no existing snapshot
- missing providers
- entries still in `loading` with no active load
When refresh fails, `refreshProvider()` stores:
```text
status: "error"
error: "Timed out refreshing OpenCode after 30000ms"
```
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
```text
refreshSettingsSnapshot()
clearCachedProviders()
resetSnapshotToLoading()
refreshProviders(... force: true)
```
That is why you have to force a manual refresh.
## Git Work During The Repro
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
Total git commands in the dev-style copied-home daemon log:
```text
632 spawned
632 closed
```
Top cwd counts:
| Count | Cwd |
| ----: | ------------------------------------------------------------------------------------- |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
| 24 | `/Users/moboudra/dev/paseo` |
| 24 | `/Users/moboudra/dev/blankpage/editor` |
| 24 | `/Users/moboudra/dev/faro/main` |
| 24 | `/Users/moboudra/dev/konbert/web` |
| 24 | `/Users/moboudra/dev/paseo-cloud` |
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
```text
142 git command spawns
150 git command closes
```
The main repeated command shapes were:
```text
76 git rev-parse --show-toplevel
72 git status --porcelain
72 git show-ref --verify --quiet refs/remotes/origin/main
72 git show-ref --verify --quiet refs/heads/main
16 git config --get branch.main.remote
16 git config --get branch.main.merge
16 git rev-list --count main..origin/main
16 git rev-list --count origin/main..main
```
## Original `log.txt` Alignment
The original startup showed the same home and paseo outer timeout shape:
```text
16:04:22.466 /Users/moboudra/dev/paseo:
Timed out refreshing OpenCode after 30000ms
16:04:22.482 /Users/moboudra:
Timed out refreshing OpenCode after 30000ms
```
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
## Files Instrumented For Diagnosis
Temporary trace instrumentation was added to:
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
The instrumentation is behavior-neutral and only emits trace logs.
@@ -1,381 +0,0 @@
# Daemon Startup Sequence Analysis - 2026-05-27
Source log: `log.txt` at repository root.
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
This report is descriptive only. It does not propose optimizations.
## Executive Summary
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
- Worker start: `16:03:46.678`, line 1.
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
- First client hello: `16:03:50.285`, line 66.
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
The startup shape is therefore:
- Daemon listen readiness: about `0.6s`.
- Client reconnect plus workspace/app/provider hydration: about `45s`.
- No git commands after workspace registry reconciliation in this slice.
## Method
I parsed structured trace lines from `log.txt`, especially:
- `Git command closed`
- `agent.session.inbound`
- `agent.session.outbound`
- `ws_slow_request`
- provider snapshot warnings
- provider resume events
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
Relevant code paths checked:
- `packages/server/src/server/session.ts`
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
- `packages/server/src/server/workspace-git-service.ts`
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
- `packages/app/src/contexts/session-context.tsx`
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
## Startup Timeline
| time | line | event |
| -------------- | ---: | ------------------------------------------------------------------ |
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
| `16:03:47.683` | 4 | worker spawned |
| `16:03:47.684` | 6 | daemon keypair loaded |
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
| `16:03:50.274` | 60 | first websocket awaiting hello |
| `16:03:50.285` | 66 | first client connected via hello |
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
| `16:04:24.306` | 1214 | OpenCode server connected event |
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
| `16:04:33.666` | 1777 | workspace registries reconciled |
| `16:04:34.197` | 1783 | OpenCode heartbeat |
| `16:04:44.200` | 1789 | OpenCode heartbeat |
## Git Command Totals
Total git commands in the sliced startup: `444`.
| phase | commands | failures | summed process time |
| ------------------------------- | -------: | -------: | ------------------: |
| daemon bootstrap before listen | 13 | 4 | 445ms |
| post-listen before first client | 1 | 0 | 2020ms |
| client reconnect + reconcile | 430 | 71 | 120813ms |
| after reconcile | 0 | 0 | 0ms |
| total | 444 | 75 | 123278ms |
Summed process time is not wall-clock time. Many commands overlap.
## Git Command Categories
| category | commands | failures | summed process time | max duration |
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
What those categories mean in the app:
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
- Dirty status: show dirty/clean workspace state.
- Upstream config and ahead/behind: show branch tracking and sync state.
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
## Per-Workspace Git Work
Columns:
- `phase`: `pre/warm/reconnect/after`
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
- `total_ms`: summed process time for that workspace
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
## Git Failure Shape
There were 75 nonzero git exits.
Most failures were not timeouts. They were expected probe failures:
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
## Inbound Client Work
Inbound session messages during the startup window:
| request | count |
| --------------------------------- | ----: |
| `client_heartbeat` | 19 |
| `checkout_pr_status_request` | 18 |
| `fetch_agents_request` | 11 |
| `fetch_workspaces_request` | 9 |
| `get_providers_snapshot_request` | 9 |
| `project_icon_request` | 9 |
| `fetch_agent_timeline_request` | 7 |
| `clear_agent_attention` | 6 |
| `list_terminals_request` | 5 |
| `subscribe_terminals_request` | 5 |
| `list_available_editors_request` | 2 |
| `subscribe_checkout_diff_request` | 2 |
| `checkout_status_request` | 1 |
| `fetch_agent_request` | 1 |
| `file_explorer_request` | 1 |
| `read_project_config_request` | 1 |
| `workspace_setup_status_request` | 1 |
Inbound by client:
| client | count | top work |
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
## Outbound Client Work
Outbound session messages during the startup window:
| message | count |
| ---------------------------------- | ----: |
| `providers_snapshot_update` | 129 |
| `workspace_update` | 81 |
| `checkout_status_update` | 76 |
| `agent_update` | 47 |
| `checkout_pr_status_response` | 18 |
| `fetch_agents_response` | 11 |
| `fetch_workspaces_response` | 9 |
| `get_providers_snapshot_response` | 9 |
| `project_icon_response` | 9 |
| `fetch_agent_timeline_response` | 7 |
| `list_terminals_response` | 5 |
| `terminals_changed` | 5 |
| `list_available_editors_response` | 2 |
| `subscribe_checkout_diff_response` | 2 |
| `checkout_status_response` | 1 |
| `fetch_agent_response` | 1 |
| `file_explorer_response` | 1 |
| `read_project_config_response` | 1 |
| `workspace_setup_status_response` | 1 |
Provider snapshot updates were large and repeated:
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
## Slow Requests
Slow requests logged during startup:
| time | request | duration | client | line |
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
## Provider Findings
### OpenCode
OpenCode provider snapshot refresh had two timeouts:
| time | line | cwd | error |
| -------------- | ---: | --------------------------- | --------------------------------------------- |
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
These are provider snapshot failures, not OpenCode agent resume failures.
The persisted OpenCode agent did resume:
| time | line | event |
| -------------- | ---: | ----------------------------------------------------- |
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
| `16:04:24.306` | 1214 | raw event `server.connected` |
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
OpenCode-related git:
- `~/dev/opencode` had 22 git commands.
- Four failed.
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
- Those failures are git state/probe failures, not OpenCode provider process failures.
### Codex
Codex provider startup observations:
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
### Claude
Claude agents resume successfully:
| time | line | client | agent |
| -------------- | ---: | -------- | -------------------------------------- |
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
## What Work The App Asked For
The startup work visible in the app/server protocol is:
- Workspace list/sidebar hydration:
- `fetch_workspaces_request`, 9 total.
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
- On the server this triggers workspace git observer sync and workspace update flushing.
- Agent list and agent detail hydration:
- `fetch_agents_request`, 11 total.
- `fetch_agent_request`, 1 total.
- `fetch_agent_timeline_request`, 7 total.
- Timeline requests are among the slowest requests in this slice.
- Checkout/PR status UI:
- `checkout_pr_status_request`, 18 total, all Electron.
- `checkout_status_request`, 1 total.
- `subscribe_checkout_diff_request`, 2 total.
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
- Provider/model/mode UI:
- `get_providers_snapshot_request`, 9 total.
- `providers_snapshot_update`, 129 outbound updates.
- OpenCode provider snapshot refresh times out twice during this flow.
- Workspace chrome:
- `project_icon_request`, 9 total.
- `file_explorer_request`, 1 total.
- Terminal panel:
- `list_terminals_request`, 5 total.
- `subscribe_terminals_request`, 5 total.
- `terminals_changed`, 5 outbound updates.
- Attention state:
- `clear_agent_attention`, 6 total.
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
## Concrete Waste-Looking Work, Without Optimizing Yet
The log shows repeated work in these exact forms:
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
- Several workspaces get repeated full checkout snapshot patterns:
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.
-179
View File
@@ -1,179 +0,0 @@
# Floating Panels
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
float above an anchor element on iOS, Android, and web. This doc captures the
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
correctly (with `overflow: visible`, the default) but **does not receive touch
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
rect first, then iterates children. A touch in the overflowing region never
reaches the parent, let alone the child. iOS and web do not share this rule —
iOS hit-test descends into overflowing children, web uses standard CSS pointer
events. This is the bug that put autocomplete on this path: the popover was
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
full-screen. This is the right default when you must keep IME attachment.
Choose the host by layer: app-global overlays use the root host; content
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
quietly relying on:
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
component's natural ancestor chain. When the user navigates away, your
component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
to the popover wrapper.
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Host-relative positioning before platform offsets
The generic anchored-overlay rule is:
1. Measure the anchor with `measureInWindow`.
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
```ts
left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash
If your popover needs `top` (or `left`) computed from both:
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
- the popover's own size (`contentSize` from `onLayout`),
then a naïve implementation will flash through three positions on every open:
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
for either measurement. Wrapper has no `width`, so the inner content lays
out at its natural (often narrow) intrinsic width.
2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`.
But the stale `onLayout` from frame 1 has already set `contentSize` to the
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
at the wrong spot.
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
updates. Position snaps to the right place.
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
both:
- **Do not mount the floating content until `anchorRect` is set.** Return
`null` until then. This prevents the bad-width onLayout from happening at
all.
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
the final width but `opacity: 0`.** The first visible paint is at the
correct position. This is the combobox pattern —
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
`top: -9999` as the placeholder; the layout work still happens at -9999 and
any subsequent state-flash is visible when you flip back.
The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Recipe for a new anchored panel
Before you write a new one, ask:
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.
+13 -20
View File
@@ -3,34 +3,27 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. 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.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
-17
View File
@@ -249,23 +249,6 @@ const { theme } = useUnistyles();
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## iOS Simulator
```bash
+3 -13
View File
@@ -24,27 +24,17 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
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.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
---
-23
View File
@@ -2,29 +2,6 @@
All workspaces share one version and release together.
## Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
## Two paths
There are two supported ways to ship from `main`:
+1 -12
View File
@@ -111,9 +111,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
### Test setup
@@ -157,12 +155,3 @@ If code isn't testable, refactor it. Signs:
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
## Two test categories, no others
Every test in this repo lives in exactly one of these shapes:
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
+28 -8
View File
@@ -4,17 +4,15 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Banned
## STOP — `useUnistyles()` Is Forbidden
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Use these alternatives in order:
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -48,9 +46,31 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. There is no "last resort"
### 4. Lift the read into a tiny leaf component
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
## How Updates Propagate
-1
View File
@@ -4,7 +4,6 @@ pre-commit:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "package-lock.json"
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files}
- name: lint
+3 -3
View File
@@ -73,10 +73,10 @@ buildNpmPackage rec {
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Server workspaces (highlight + relay + protocol + client + server + cli)
npm run build:server
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:server
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
+1 -1
View File
@@ -1 +1 @@
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=
sha256-ipuV4cM3LUqMQ39LyB4X3ezz5UgxyHGEhpBOq7en2Jw=
+2 -2
View File
@@ -79,8 +79,8 @@ buildNpmPackage rec {
# degrade when unavailable.
npm rebuild node-pty
# Build all server packages in dependency order (defined in package.json)
npm run build:server
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
+769 -256
View File
File diff suppressed because it is too large Load Diff
+8 -18
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.84",
"version": "0.1.79",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -24,8 +24,6 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/protocol",
"packages/client",
"packages/server",
"packages/app",
"packages/relay",
@@ -36,24 +34,16 @@
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -73,7 +63,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -87,9 +77,9 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
@@ -126,7 +126,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
@@ -181,7 +181,7 @@ test.describe("Client slash commands", () => {
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/exit");
await selectClientSlashCommand(page, "/qu", "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},
@@ -1,536 +0,0 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
const TEST_COMMANDS = [
{
name: "tdd",
description: "Write a red test, verify it fails for the right reason, implement to green",
argumentHint: "",
},
{
name: "help",
description: "Show help for the current agent session and available slash commands",
argumentHint: "",
},
{
name: "hello",
description: "Insert a friendly greeting prompt into the current composer",
argumentHint: "",
},
{
name: "heapdump",
description: "Dump the JavaScript heap for local desktop debugging",
argumentHint: "",
},
{
name: "health",
description: "Show runtime health checks and connection diagnostics",
argumentHint: "",
},
{
name: "history",
description: "Summarize recent session history",
argumentHint: "",
},
{
name: "handoff",
description: "Prepare a complete handoff note for another agent",
argumentHint: "[agent]",
},
{
name: "hover",
description: "Audit hover behavior in desktop web surfaces",
argumentHint: "",
},
{
name: "harness",
description: "Inspect the local test harness configuration",
argumentHint: "",
},
{
name: "hydrate",
description: "Refresh persisted state used by the current workspace",
argumentHint: "",
},
{
name: "highlight",
description: "Highlight important changes in the active diff",
argumentHint: "",
},
{
name: "home",
description: "Navigate back to the workspace home surface",
argumentHint: "",
},
{
name: "host",
description: "Inspect host connection metadata",
argumentHint: "",
},
] as const;
interface PopoverFrame {
exists: boolean;
top: number;
bottom: number;
height: number;
opacity: number;
display: string;
visibility: string;
timestamp: number;
}
interface PopoverFrameRecorderWindow extends Window {
__composerAutocompleteFrames?: PopoverFrame[];
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort || daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
}
return daemonPort;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
ws.send(message);
return;
}
try {
const parsed = JSON.parse(message) as {
type?: string;
message?: {
type?: string;
payload?: {
commands?: unknown;
error?: string | null;
};
};
};
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
return;
}
} catch {
// Forward non-JSON frames unchanged.
}
ws.send(message);
});
});
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
const operation = cleanup().catch(() => undefined);
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>;
}> {
const repo = await createTempGitRepo("autocomplete-popover-");
const client = await connectTerminalClient();
try {
await openProject(client, repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Autocomplete popover regression",
modeId: "load-test",
model: "ten-second-stream",
});
const route = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(route);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, agent.id);
}
await expectComposerVisible(page);
return {
cleanup: async () => {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
},
};
} catch (error) {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
throw error;
}
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
popover.evaluate((element) => {
const style = window.getComputedStyle(element);
return Number(style.opacity);
}),
{ timeout: 30_000 },
)
.toBeGreaterThan(0.95);
const box = await popover.boundingBox();
if (!box) {
throw new Error("Autocomplete popover did not produce a bounding box.");
}
return {
top: box.y,
bottom: box.y + box.height,
height: box.height,
};
}
async function startPopoverFrameRecorder(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__composerAutocompleteFrames = [];
let active = true;
const record = () => {
if (!active) return;
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
if (element instanceof HTMLElement) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
win.__composerAutocompleteFrames?.push({
exists: true,
top: rect.top,
bottom: rect.bottom,
height: rect.height,
opacity: Number(style.opacity),
display: style.display,
visibility: style.visibility,
timestamp: performance.now(),
});
} else {
win.__composerAutocompleteFrames?.push({
exists: false,
top: 0,
bottom: 0,
height: 0,
opacity: 0,
display: "none",
visibility: "hidden",
timestamp: performance.now(),
});
}
window.requestAnimationFrame(record);
};
win.__stopComposerAutocompleteFrameRecorder = () => {
active = false;
};
window.requestAnimationFrame(record);
});
}
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
return page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__stopComposerAutocompleteFrameRecorder?.();
return win.__composerAutocompleteFrames ?? [];
});
}
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
return frames.filter(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
}
function formatFrame(frame: PopoverFrame | undefined): string {
if (!frame) return "none";
return JSON.stringify({
top: Math.round(frame.top),
bottom: Math.round(frame.bottom),
height: Math.round(frame.height),
opacity: frame.opacity,
});
}
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
const visibleFrames = visiblePopoverFrames(frames);
expect(visibleFrames.length).toBeGreaterThan(0);
const finalFrame = visibleFrames[visibleFrames.length - 1];
const jumpingFrame = visibleFrames.find(
(frame) =>
Math.abs(frame.top - finalFrame.top) > 4 ||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
Math.abs(frame.height - finalFrame.height) > 4,
);
expect(
jumpingFrame,
`expected first visible popover paint to be stable; first=${formatFrame(
visibleFrames[0],
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
).toBeUndefined();
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
const hiddenFrame = frames
.slice(firstVisibleIndex)
.find((frame) => !frame.exists || frame.opacity < 0.95);
expect(
hiddenFrame,
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
).toBeUndefined();
}
test.describe("Composer autocomplete", () => {
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.click();
await startPopoverFrameRecorder(page);
await page.keyboard.type("/");
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverFramesStable(frames);
} finally {
await agent.cleanup();
}
});
test("does not jump when deleting a slash command search", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
} finally {
await agent.cleanup();
}
});
test("shrinks to filtered slash command results without moving the bottom edge", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const allCommands = await visiblePopoverBox(page);
await input.fill("/tdd");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const oneCommand = await visiblePopoverBox(page);
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
} finally {
await agent.cleanup();
}
});
test("stays visible while filtering slash command results", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await startPopoverFrameRecorder(page);
await page.keyboard.type("tdd", { delay: 40 });
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
} finally {
await agent.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const composerBox = await page.getByTestId("message-input-root").boundingBox();
const popoverBox = await popover.boundingBox();
expect(composerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
});
});
});
+4 -13
View File
@@ -321,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build:relay", {
execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot,
stdio: "inherit",
});
@@ -537,22 +537,13 @@ async function awaitRelayReady(
}
}
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
async function startRelay(): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePortExcluding(excludedPorts);
const relayPort = await getAvailablePort();
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -738,7 +729,7 @@ export default async function globalSetup() {
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
const relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -90,11 +91,21 @@ interface DaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<
DaemonClientConfig,
DaemonClientInstance
>();
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
+16 -5
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
@@ -82,11 +83,21 @@ interface ArchiveTabDaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
ArchiveTabDaemonClientConfig,
ArchiveTabDaemonClient
>();
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
@@ -1,15 +0,0 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
new (config: ClientConfig) => ClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ClientConfig) => ClientInstance;
};
return mod.DaemonClient;
}
+18 -7
View File
@@ -1,13 +1,14 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient,
ServerDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "close"
@@ -47,6 +48,19 @@ function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
@@ -69,10 +83,7 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
NewWorkspaceDaemonClientConfig,
NewWorkspaceDaemonClient
>();
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
-348
View File
@@ -1,348 +0,0 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: TerminalPerfDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
}
export interface TranscriptMessage {
role: "user" | "assistant";
text: string | RegExp;
}
interface ProviderLaunchConfig {
provider: RewindFlowProvider;
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
return { provider, model: "haiku", modeId: "bypassPermissions" };
case "codex":
return {
provider,
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
};
case "opencode":
return {
provider,
model: "opencode/big-pickle",
modeId: "build",
featureValues: { auto_accept: true },
};
case "pi":
return {
provider,
model: "openrouter/google/gemini-2.5-flash-lite",
thinkingOptionId: "medium",
};
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await assertComposerIdle({ page });
}
function visibleChatMessages(page: Page) {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
}
async function transcript(
page: Page,
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
elements.map((element) => ({
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
| "user"
| "assistant",
text: (element.textContent ?? "")
.replace(/\s+/g, " ")
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
.trim(),
})),
);
return coalesceAssistantTurnSegments(rawMessages);
}
function coalesceAssistantTurnSegments(
messages: Array<{ role: "user" | "assistant"; text: string }>,
): Array<{ role: "user" | "assistant"; text: string }> {
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const message of messages) {
const previous = transcriptMessages.at(-1);
if (message.role === "assistant" && previous?.role === "assistant") {
const joinedText =
previous.text && message.text
? `${previous.text}\n${message.text}`
: previous.text || message.text;
transcriptMessages[transcriptMessages.length - 1] = {
role: "assistant",
text: joinedText,
};
continue;
}
transcriptMessages.push(message);
}
return transcriptMessages.filter(
(message) => message.role !== "assistant" || message.text.length > 0,
);
}
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
if (typeof expected === "string") {
return actual === expected;
}
return expected.test(actual);
}
function formatExpectedMessage(message: TranscriptMessage): string {
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
return `${message.role}:${text}`;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export async function launchAgent(input: {
page: Page;
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: input.cwd,
stdio: "ignore",
});
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectTerminalClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.close().catch(() => undefined);
}
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
const before = await transcript(handle.page);
await submitMessage(handle.page, text);
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
if (finish.status !== "idle") {
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
throw new Error(
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
);
}
if (finish.final?.lastError) {
throw new Error(finish.final.lastError);
}
await expect
.poll(async () => transcript(handle.page), { timeout: 30_000 })
.toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", text }),
expect.objectContaining({ role: "assistant" }),
]),
);
await expect
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
timeout: 30_000,
})
.toBeGreaterThanOrEqual(before.length + 2);
await assertComposerIdle(handle);
}
export async function assertChatTranscript(
handle: Pick<AgentHandle, "page">,
expected: TranscriptMessage[],
): Promise<void> {
await expect
.poll(
async () => {
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
return JSON.stringify(actual);
}
const matches = actual.every(
(message, index) =>
message.role === expected[index]?.role &&
expectedTextMatches(message.text, expected[index]!.text),
);
return matches ? "match" : JSON.stringify(actual);
},
{ timeout: 30_000 },
)
.toBe("match");
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
throw new Error(
`Expected ${expected.length} chat messages (${expected
.map(formatExpectedMessage)
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
);
}
}
export async function rewindMessage(
handle: AgentHandle,
userMessageIndex: number,
mode: RewindFlowMode,
): Promise<void> {
const beforeEpoch = await fetchTimelineEpoch(handle);
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
await expect(userMessage).toBeVisible({ timeout: 30_000 });
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
const userText = userMessages[userMessageIndex]?.text;
if (!userText) {
throw new Error(`No user message found at index ${userMessageIndex}`);
}
await userMessage
.getByText(new RegExp(escapeRegExp(userText)))
.first()
.hover();
const trigger = userMessage.getByTestId("rewind-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
await expect(modeItem).toBeVisible({ timeout: 10_000 });
await modeItem.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
if (mode !== "files") {
await waitForNextTimelineEpoch(handle, beforeEpoch);
}
await assertComposerIdle(handle);
}
export async function assertFileExists(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
}
export async function assertFileMissing(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
}
export async function assertFileContains(filePath: string, text: string): Promise<void> {
await assertFileExists(filePath);
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
}
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
}
export async function cleanupRewindFlow(input: {
handle?: AgentHandle;
cwd: string;
}): Promise<void> {
if (input.handle) {
await closeAgent(input.handle);
}
rmSync(input.cwd, { recursive: true, force: true });
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as TerminalPerfDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
limit: 0,
});
return timeline.epoch;
}
async function waitForNextTimelineEpoch(
handle: AgentHandle,
beforeEpoch: string | undefined,
): Promise<void> {
await expect
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
.not.toBe(beforeEpoch);
}
+15 -25
View File
@@ -1,8 +1,7 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -30,20 +29,12 @@ export interface TerminalPerfDaemonClient {
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -77,36 +68,35 @@ interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
TerminalPerfDaemonClientConfig,
TerminalPerfDaemonClient
>();
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory,
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, workspaceId);
+24 -8
View File
@@ -1,12 +1,13 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { SessionOutboundMessage } from "@server/shared/messages";
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
@@ -69,16 +70,31 @@ function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${daemonPort}/ws`;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
{
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
},
WorkspaceSetupDaemonClient
>();
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
@@ -1,7 +0,0 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "claude",
rewindMode: "both",
fileReverted: true,
});
@@ -1,7 +0,0 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "codex",
rewindMode: "conversation",
fileReverted: false,
});
@@ -1,8 +0,0 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "opencode",
initialRewindMode: "both",
rewindMode: "both",
fileReverted: true,
});
@@ -1,7 +0,0 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "pi",
rewindMode: "conversation",
fileReverted: false,
});
-88
View File
@@ -1,88 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "./fixtures";
import {
assertChatTranscript,
assertComposerIdle,
assertFileContains,
assertFileExists,
assertFileMissing,
cleanupRewindFlow,
launchAgent,
rewindMessage,
sendMessage,
type AgentHandle,
type RewindFlowMode,
type RewindFlowProvider,
} from "./helpers/rewind-flow";
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
interface RewindFlowCase {
provider: RewindFlowProvider;
initialRewindMode?: RewindFlowMode;
rewindMode: RewindFlowMode;
fileReverted: boolean;
}
export function defineRewindFlowSpec(input: RewindFlowCase): void {
test.describe(`rewind flow - ${input.provider}`, () => {
test.setTimeout(600_000);
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: input.provider,
cwd,
mode: "full-access",
});
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
await assertChatTranscript(handle, []);
await assertComposerIdle(handle);
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await sendMessage(handle, FILE_PROMPT);
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
{ role: "user", text: /Use the Write tool/ },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 1, input.rewindMode);
if (input.fileReverted) {
await assertFileMissing(path.join(cwd, "qa.txt"));
} else {
await assertFileExists(path.join(cwd, "qa.txt"));
}
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await assertComposerIdle(handle);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
}
@@ -1,167 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const repo = await createTempGitRepo("rewind-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await client.close();
await repo.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const repo = await createTempGitRepo("rewind-failure-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind failure e2e",
modeId: "load-test",
model: "ten-second-stream",
featureValues: {
mockRewindError: rewindError,
},
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -1,61 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
const OSC11_CAPTURE_SCRIPT = `
let captured = Buffer.alloc(0);
function finish() {
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
process.exit(0);
}
process.stdout.write("\\x1b]11;?\\x07");
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.on("data", (chunk) => {
captured = Buffer.concat([captured, chunk]);
if (captured.includes(Buffer.from("rgb:"))) {
finish();
}
});
setTimeout(finish, 700);
`;
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
try {
await harness.openTerminal(page, { terminalId: terminalInstance.id });
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
await page.waitForTimeout(500);
const text = await getTerminalBufferText(page);
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
expect(text).not.toContain("rgb:ffff/ffff/ffff");
} finally {
await harness.killTerminal(terminalInstance.id);
}
});
});
@@ -1,92 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
async function expectIdleComposer(page: Page): Promise<void> {
await expectComposerEditable(page);
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 15_000,
});
}
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
await expectIdleComposer(page);
await page.waitForTimeout(1_000);
await expectIdleComposer(page);
}
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const repo = await createTempGitRepo("user-message-contract-e2e-");
const client = await connectTerminalClient();
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
"emit 1 coalesced agent stream updates for user message contract turn three.",
];
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "User message contract e2e",
modeId: "load-test",
model: "ten-second-stream",
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
await submitMessage(page, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
timeout: 15_000,
});
await expectUserMessageCount(page, index + 1);
await expectNoLoadingRegressionAfterIdle(page);
}
await fillComposerDraft(page, "append");
await composerLocator(page).evaluate((element) => element.blur());
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -1,239 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { captureWsSessionFrames } from "./helpers/rename";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface WorkspaceTabProbeRecord {
at: number;
tabs: Array<{
testId: string;
text: string;
ariaLabel: string;
}>;
}
interface CapturedCreateAgentFrame {
initialPrompt: string | null;
configTitle: string | null;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function installWorkspaceTabProbe(page: Page): Promise<void> {
await page.evaluate(() => {
type ProbeRecord = WorkspaceTabProbeRecord;
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
};
const win = window as ProbeWindow;
win.__workspaceTabTitleProbe?.stop();
const records: ProbeRecord[] = [];
const isVisible = (element: Element): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const snapshot = () => {
records.push({
at: performance.now(),
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
.filter(isVisible)
.map((element) => ({
testId: element.getAttribute("data-testid") ?? "",
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
ariaLabel: element.getAttribute("aria-label") ?? "",
})),
});
};
snapshot();
const observer = new MutationObserver(snapshot);
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["aria-label", "class", "data-testid", "style"],
});
const interval = window.setInterval(snapshot, 20);
win.__workspaceTabTitleProbe = {
records,
stop: () => {
observer.disconnect();
window.clearInterval(interval);
},
};
});
}
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
return page.evaluate(() => {
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
};
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
probe?.stop();
return probe?.records ?? [];
});
}
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
}
function createFrameStartsWithPrompt(
frame: CapturedCreateAgentFrame,
promptTitle: string,
): boolean {
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
}
function countCreateFramesForPrompt(
frames: CapturedCreateAgentFrame[],
promptTitle: string,
): number {
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
}
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
}
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
return record.tabs.some(tabHasLoadingTitle);
}
async function waitForCreatedAgentId(
client: TerminalPerfDaemonClient,
cwd: string,
): Promise<string> {
await expect
.poll(
async () => {
const result = await client.fetchAgents({ scope: "active" });
return result.entries
.filter((entry) => entry.agent.cwd === cwd)
.map((entry) => entry.agent.id);
},
{ timeout: 30_000 },
)
.toHaveLength(1);
const result = await client.fetchAgents({ scope: "active" });
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
if (!agent) {
throw new Error(`Expected one created agent in ${cwd}`);
}
return agent.agent.id;
}
async function fetchActiveAgentTitle(
client: TerminalPerfDaemonClient,
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
}
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
const promptTab = page.getByRole("button", { name: promptTitle }).first();
await expect(promptTab).toBeVisible({ timeout: 15_000 });
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
await promptTab.click({ button: "right" });
const renameAction = page.getByText("Rename", { exact: true }).first();
if (await renameAction.isVisible().catch(() => false)) {
await page.keyboard.press("Escape");
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(100);
}
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
}
test.describe("Workspace agent title handoff", () => {
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1440, height: 900 });
const client = await connectTerminalClient();
const repo = await createTempGitRepo("workspace-title-handoff-");
try {
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? "Failed to open test workspace");
}
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
configTitle: typeof config.title === "string" ? config.title : null,
};
});
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").click();
await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff";
const generatedTitle = "Generated Handoff Title";
await installWorkspaceTabProbe(page);
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
const agentId = await waitForCreatedAgentId(client, repo.path);
await expect
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
timeout: 10_000,
})
.toBe(1);
expect(createFrames.at(-1)).toEqual({
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
configTitle: null,
});
await waitForPromptTabAgentActions(page, promptTitle);
await client.updateAgent(agentId, { name: generatedTitle });
await expect
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
.toBe(generatedTitle);
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
timeout: 15_000,
});
const records = await readWorkspaceTabProbe(page);
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});
@@ -15,7 +15,6 @@ OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
VIDEO_DIR="/tmp/paseo-maestro-videos"
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
@@ -56,12 +55,6 @@ require_command perl
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
if [ ! -f "$CLIENT_EXPORTS" ]; then
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
echo "Run: npm run build:client" >&2
exit 1
fi
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
@@ -103,7 +96,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
@@ -62,9 +62,9 @@ render_flow_tree() {
done
}
if [ ! -f "$CLIENT_EXPORTS" ]; then
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
echo "Run: npm run build:client" >&2
if [ ! -f "$SERVER_EXPORTS" ]; then
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
echo "Run: npm run build --workspace=@getpaseo/server" >&2
exit 1
fi
@@ -117,7 +117,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
+6 -1
View File
@@ -6,6 +6,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.trim()
@@ -67,7 +68,11 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
if (
origin &&
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
moduleName.endsWith(".js")
) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
+22 -31
View File
@@ -1,43 +1,38 @@
{
"name": "@getpaseo/app",
"version": "0.1.84",
"version": "0.1.79",
"private": true,
"main": "index.ts",
"scripts": {
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
"start:expo": "cross-env APP_VARIANT=development expo start",
"start": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:real": "playwright test --project=real-provider",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
"build:web": "npm run build:workspace-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/client": "*",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -46,15 +41,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -80,7 +75,6 @@
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -96,7 +90,6 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -108,15 +101,13 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
-6
View File
@@ -27,12 +27,6 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
name: "real-provider",
testMatch: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

-8
View File
@@ -4,16 +4,10 @@
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="robots" content="noindex,nofollow" />
<meta name="theme-color" content="#181B1A" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Paseo" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
@@ -25,8 +19,6 @@
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
-27
View File
@@ -1,27 +0,0 @@
{
"id": "/",
"name": "Paseo",
"short_name": "Paseo",
"description": "Monitor and control local AI coding agents from anywhere.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#181B1A",
"background_color": "#181B1A",
"categories": ["developer", "productivity", "utilities"],
"icons": [
{
"src": "/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

@@ -1,89 +0,0 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js"),
path.join(basePath, "index.jsx"),
];
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try next candidate
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
build.onResolve({ filter: /^@server\// }, async (args) => ({
path: await resolveTsPath(
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
loader: {
".css": "text",
},
plugins: [aliasPlugin],
logLevel: "info",
});
const js = result.outputFiles[0]?.text;
if (!js) {
throw new Error("terminal webview bundle produced no JavaScript");
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
@@ -1,292 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
block?: { groupId: string; index: number },
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
};
}
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: timestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "Shell",
arguments: "echo hi",
result: null,
status: "completed",
},
},
};
}
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
return {
kind: "thought",
id,
text: id,
timestamp: timestamp(seed),
status: "ready",
};
}
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
return new Map(ids.map((id) => [id, timing]));
}
function strategyFor(platform: "web" | "android"): StreamStrategy {
return resolveStreamRenderStrategy({
platform,
isMobileBreakpoint: false,
});
}
function layoutFor(input: {
platform: "web" | "android";
agentStatus?: string;
tail: StreamItem[];
head?: StreamItem[];
timingIds?: string[];
}): StreamLayout {
const strategy = strategyFor(input.platform);
return layoutStream({
strategy,
agentStatus: input.agentStatus ?? "idle",
history: orderTailForStreamRenderStrategy({
strategy,
streamItems: input.tail,
}),
liveHead: orderHeadForStreamRenderStrategy({
strategy,
streamHead: input.head ?? [],
}),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
function footerOwners(layout: StreamLayout): string[] {
const owners = [
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
return owners;
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
);
if (!item) {
throw new Error(`Missing layout item ${id}`);
}
return item;
}
describe("layoutStream", () => {
it.each(["web", "android"] as const)(
"keeps split assistant block spacing identical to unsplit history on %s",
(platform) => {
const firstBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const secondBlock = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 });
const splitLayout = layoutFor({
platform,
agentStatus: "running",
tail: [userMessage("u1", 1), firstBlock],
head: [secondBlock, thirdBlock],
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
});
const unsplitLayout = layoutFor({
platform,
agentStatus: "running",
tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock],
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
});
expect(findLayoutItem(splitLayout, firstBlock.id).belowItem?.id).toBe(secondBlock.id);
expect(findLayoutItem(splitLayout, secondBlock.id).aboveItem?.id).toBe(firstBlock.id);
expect(findLayoutItem(splitLayout, firstBlock.id).assistantSpacing).toBe(
findLayoutItem(unsplitLayout, firstBlock.id).assistantSpacing,
);
expect(findLayoutItem(splitLayout, secondBlock.id).assistantSpacing).toBe(
findLayoutItem(unsplitLayout, secondBlock.id).assistantSpacing,
);
expect(findLayoutItem(splitLayout, firstBlock.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, firstBlock.id).gapBelow,
);
expect(findLayoutItem(splitLayout, secondBlock.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, secondBlock.id).gapBelow,
);
},
);
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
});
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
});
it("keeps the completed footer visually after the assistant after a native user reply", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.belowItem?.id).toBe("u2");
expect(assistantRow.frameOrder).toBe("footer-then-content");
});
it("keeps forward stream content before its completed footer", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("compacts assistant block spacing across the history and live-head boundary", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
});
it.each(["web", "android"] as const)(
"keeps split tool sequencing and gapBelow identical to unsplit history on %s",
(platform) => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
const assistant = assistantMessage("a1", 4);
const splitLayout = layoutFor({
platform,
tail: [userMessage("u1", 1), shell],
head: [thinking, assistant],
});
const unsplitLayout = layoutFor({
platform,
tail: [userMessage("u1", 1), shell, thinking, assistant],
});
expect(findLayoutItem(splitLayout, shell.id).belowItem?.id).toBe(thinking.id);
expect(findLayoutItem(splitLayout, thinking.id).aboveItem?.id).toBe(shell.id);
expect(findLayoutItem(splitLayout, shell.id).toolSequence).toBe(
findLayoutItem(unsplitLayout, shell.id).toolSequence,
);
expect(findLayoutItem(splitLayout, thinking.id).toolSequence).toBe(
findLayoutItem(unsplitLayout, thinking.id).toolSequence,
);
expect(findLayoutItem(splitLayout, shell.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, shell.id).gapBelow,
);
expect(findLayoutItem(splitLayout, thinking.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, thinking.id).gapBelow,
);
},
);
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
});
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
});
it("keeps bottom and inline footer ownership mutually exclusive", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
});
});
-246
View File
@@ -1,246 +0,0 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
completedFooter: TurnFooterHost | null;
toolSequence: StreamToolSequence;
isFirstInUserGroup: boolean;
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
}
export interface StreamLayout {
history: StreamLayoutItem[];
liveHead: StreamLayoutItem[];
auxiliaryTurnFooter: TurnFooterHost | null;
}
export interface StreamLayoutInput {
strategy: StreamStrategy;
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
}
function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) {
return null;
}
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: startIndex,
timingByAssistantId: input.timingByAssistantId,
});
}
function shouldRenderCompletedFooter(input: {
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean {
return (
input.item.kind === "assistant_message" &&
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running"))
);
}
function isToolSequenceItem(item: StreamItem | null): boolean {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
function getToolSequence(input: {
item: StreamItem;
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
}): StreamToolSequence {
if (!isToolSequenceItem(input.item)) {
return "none";
}
const hasAbove = isToolSequenceItem(input.aboveItem);
const hasBelow = isToolSequenceItem(input.belowItem);
if (hasAbove && hasBelow) {
return "middle";
}
if (hasAbove) {
return "last";
}
if (hasBelow) {
return "first";
}
return "single";
}
function getSegmentNeighbor(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: "above" | "below";
boundaryIndex: number | null;
boundaryItem: StreamItem | null;
}): StreamItem | null {
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
if (neighbor) {
return neighbor;
}
if (input.index === input.boundaryIndex) {
return input.boundaryItem;
}
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
item,
belowItem,
agentStatus: input.agentStatus,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
};
});
}
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
const liveHeadBoundaryItem =
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
const frameOrder = input.strategy.getFrameChildOrder();
const history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
});
return {
history,
liveHead,
auxiliaryTurnFooter,
};
}
-4
View File
@@ -28,10 +28,8 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -459,7 +457,6 @@ function AppContainer({
)}
<View style={flexStyle}>{children}</View>
</View>
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<RosettaCalloutSource />
@@ -467,7 +464,6 @@ function AppContainer({
<WorktreeSetupCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
+149
View File
@@ -0,0 +1,149 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
redirectMock: vi.fn(),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.bootstrapState = {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
redirectMock.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
});
it("restores the persisted workspace even when the first online host is different", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when no persisted workspace exists", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = null;
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when the give-up timer fires with no host online", async () => {
state.bootstrapState = {
...state.bootstrapState,
hasGivenUpWaitingForHost: true,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
@@ -360,25 +360,6 @@ describe("parseAssistantFileLink", () => {
}),
).toBeNull();
});
it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => {
// Regressions for tool output strings like ping's "100% packet loss",
// Windows "%PATH%" references, and percentages such as "0% off".
// decodeURIComponent throws URIError on these; the parser must swallow
// it and return null rather than crash the renderer.
const cases = [
"/tmp/100% packet loss",
"/Users/test/project/0% off",
"/var/log/%PATH%/x.log",
"file:///tmp/100% packet loss",
];
for (const value of cases) {
expect(() =>
parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }),
).not.toThrow();
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
});
describe("normalizeInlinePathTarget", () => {
+2 -10
View File
@@ -1,13 +1,5 @@
import { isAbsolutePath } from "@/utils/path";
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export interface InlinePathTarget {
raw: string;
path: string;
@@ -358,7 +350,7 @@ export function parseAssistantFileLink(
return null;
}
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -667,7 +659,7 @@ function normalizeFileUrlPath(pathname: string): string | null {
return null;
}
const decoded = safeDecodeURIComponent(pathname).replace(/\\/g, "/");
const decoded = decodeURIComponent(pathname).replace(/\\/g, "/");
if (!decoded) {
return null;
}
@@ -1,54 +0,0 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
export type AttachmentFileInfo =
| { exists: true; isDirectory: boolean; size: number | null }
| { exists: false };
export interface AttachmentFileSystem {
readonly cacheDirectory: string | null;
getInfo(uri: string): Promise<AttachmentFileInfo>;
makeDirectory(uri: string, options: { intermediates: boolean }): Promise<void>;
writeBytes(uri: string, bytes: Uint8Array): Promise<void>;
copy(input: { from: string; to: string }): Promise<void>;
readAsBase64(uri: string): Promise<string>;
delete(uri: string, options: { idempotent: boolean }): Promise<void>;
listDirectory(uri: string): Promise<string[]>;
}
export function createExpoAttachmentFileSystem(): AttachmentFileSystem {
return {
cacheDirectory: FileSystem.cacheDirectory,
async getInfo(uri) {
const info = await FileSystem.getInfoAsync(uri);
if (!info.exists) {
return { exists: false };
}
const size =
typeof (info as { size?: number }).size === "number"
? (info as { size: number }).size
: null;
return { exists: true, isDirectory: info.isDirectory ?? false, size };
},
async makeDirectory(uri, options) {
await FileSystem.makeDirectoryAsync(uri, options);
},
async writeBytes(uri, bytes) {
new File(uri).write(bytes);
},
async copy(input) {
await FileSystem.copyAsync(input);
},
async readAsBase64(uri) {
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
},
async delete(uri, options) {
await FileSystem.deleteAsync(uri, options);
},
async listDirectory(uri) {
return await FileSystem.readDirectoryAsync(uri);
},
};
}
@@ -1,14 +1,64 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
const fileSystemMock = vi.hoisted(() => ({
getInfoAsync: vi.fn(async (uri: string) => {
if (uri.endsWith("/preview-assets/")) {
return { exists: true, isDirectory: true };
}
return { exists: true, isDirectory: false, size: 4 };
}),
makeDirectoryAsync: vi.fn(async () => {}),
writeAsStringAsync: vi.fn(async () => {}),
copyAsync: vi.fn(async () => {}),
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
}));
vi.mock("expo-file-system", () => ({
File: class {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
write(content: Uint8Array) {
fileSystemMock.fileWrite(this.uri, content);
}
},
}));
vi.mock("expo-file-system/legacy", () => ({
cacheDirectory: "file:///cache/",
EncodingType: { Base64: "base64" },
getInfoAsync: fileSystemMock.getInfoAsync,
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
copyAsync: fileSystemMock.copyAsync,
readAsStringAsync: fileSystemMock.readAsStringAsync,
deleteAsync: fileSystemMock.deleteAsync,
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
}));
describe("local file attachment store", () => {
beforeEach(() => {
fileSystemMock.getInfoAsync.mockClear();
fileSystemMock.makeDirectoryAsync.mockClear();
fileSystemMock.writeAsStringAsync.mockClear();
fileSystemMock.copyAsync.mockClear();
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
fileSystemMock.fileWrite.mockClear();
});
it("writes raw byte sources directly to the managed file path", async () => {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
@@ -19,6 +69,11 @@ describe("local file attachment store", () => {
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
});
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -27,9 +82,5 @@ describe("local file attachment store", () => {
fileName: "result.png",
byteSize: 4,
});
expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual(
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true);
});
});
@@ -1,4 +1,5 @@
import type { AttachmentFileSystem } from "@/attachments/attachment-file-system";
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import {
type AttachmentStore,
type AttachmentStorageType,
@@ -36,12 +37,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
}
async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise<void> {
const info = await fileSystem.getInfo(uri);
async function ensureDirectory(uri: string): Promise<void> {
const info = await FileSystem.getInfoAsync(uri);
if (info.exists && info.isDirectory) {
return;
}
await fileSystem.makeDirectory(uri, { intermediates: true });
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
@@ -54,7 +55,6 @@ async function blobToBytes(blob: Blob): Promise<Uint8Array> {
}
async function writeFromSource(input: {
fileSystem: AttachmentFileSystem;
source: SaveAttachmentInput["source"];
targetUri: string;
mimeType: string;
@@ -64,7 +64,7 @@ async function writeFromSource(input: {
if (from === input.targetUri) {
return;
}
await input.fileSystem.copy({ from, to: input.targetUri });
await FileSystem.copyAsync({ from, to: input.targetUri });
return;
}
@@ -77,7 +77,7 @@ async function writeFromSource(input: {
bytes = input.source.bytes;
}
await input.fileSystem.writeBytes(input.targetUri, bytes);
new File(input.targetUri).write(bytes);
}
function attachmentUri(metadata: AttachmentMetadata): string {
@@ -87,13 +87,11 @@ function attachmentUri(metadata: AttachmentMetadata): string {
export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
fileSystem: AttachmentFileSystem;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const { fileSystem } = params;
const baseDirectory = fileSystem.cacheDirectory
? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/`
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
: null;
async function resolveTarget(input: SaveAttachmentInput): Promise<{
@@ -105,10 +103,10 @@ export function createLocalFileAttachmentStore(params: {
storageKey: string;
}> {
if (!baseDirectory) {
throw new Error("Attachment file-system cacheDirectory is unavailable.");
throw new Error("expo-file-system cacheDirectory is unavailable.");
}
await ensureDirectory(fileSystem, baseDirectory);
await ensureDirectory(baseDirectory);
const id = input.id ?? generateAttachmentId();
let mimeTypeFromSource: string | undefined;
@@ -142,14 +140,16 @@ export function createLocalFileAttachmentStore(params: {
async save(input): Promise<AttachmentMetadata> {
const target = await resolveTarget(input);
await writeFromSource({
fileSystem,
source: input.source,
targetUri: target.targetUri,
mimeType: target.mimeType,
});
const info = await fileSystem.getInfo(target.targetUri);
const byteSize = info.exists ? info.size : null;
const info = await FileSystem.getInfoAsync(target.targetUri);
const byteSize =
info.exists && typeof (info as { size?: number }).size === "number"
? (info as { size: number }).size
: null;
return {
id: target.id,
mimeType: target.mimeType,
@@ -162,7 +162,10 @@ export function createLocalFileAttachmentStore(params: {
},
async encodeBase64({ attachment }): Promise<string> {
return await fileSystem.readAsBase64(attachmentUri(attachment));
const uri = attachmentUri(attachment);
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
},
async resolvePreviewUrl({ attachment }): Promise<string> {
@@ -181,22 +184,22 @@ export function createLocalFileAttachmentStore(params: {
: {}),
async delete({ attachment }): Promise<void> {
await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise<void> {
if (!baseDirectory) {
return;
}
await ensureDirectory(fileSystem, baseDirectory);
const entries = await fileSystem.listDirectory(baseDirectory);
await ensureDirectory(baseDirectory);
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
await Promise.all(
entries.map(async (entryName) => {
const id = entryName.split(".", 1)[0] ?? "";
if (!id || referencedIds.has(id)) {
return;
}
await fileSystem.delete(`${baseDirectory}${entryName}`, {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
}),
@@ -1,4 +1,3 @@
import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system";
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
@@ -6,7 +5,6 @@ export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "paseo-native-attachments",
fileSystem: createExpoAttachmentFileSystem(),
resolvePreviewUrl: async (attachment) => {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
+1 -3
View File
@@ -9,9 +9,7 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } =
await import("../desktop/attachments/desktop-attachment-store");
const { createDesktopAttachmentBridge } =
await import("../desktop/attachments/desktop-attachment-bridge");
return createDesktopAttachmentStore(createDesktopAttachmentBridge());
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
@@ -1,95 +0,0 @@
import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
export interface TestAttachmentFileSystem extends AttachmentFileSystem {
readonly files: ReadonlyMap<string, Uint8Array>;
readonly directories: ReadonlySet<string>;
setFile(uri: string, bytes: Uint8Array): void;
setDirectory(uri: string): void;
}
export function createTestAttachmentFileSystem(options?: {
cacheDirectory?: string | null;
}): TestAttachmentFileSystem {
const files = new Map<string, Uint8Array>();
const directories = new Set<string>();
const cacheDirectory =
options && Object.hasOwn(options, "cacheDirectory")
? (options.cacheDirectory ?? null)
: "file:///cache/";
function describe(uri: string): AttachmentFileInfo {
if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) {
return { exists: true, isDirectory: true, size: null };
}
const bytes = files.get(uri);
if (bytes) {
return { exists: true, isDirectory: false, size: bytes.byteLength };
}
return { exists: false };
}
return {
files,
directories,
cacheDirectory,
setFile(uri, bytes) {
files.set(uri, bytes);
},
setDirectory(uri) {
directories.add(stripTrailingSlash(uri));
},
async getInfo(uri) {
return describe(uri);
},
async makeDirectory(uri) {
directories.add(stripTrailingSlash(uri));
},
async writeBytes(uri, bytes) {
files.set(uri, bytes);
},
async copy({ from, to }) {
const bytes = files.get(from);
if (!bytes) {
throw new Error(`copy: source does not exist: ${from}`);
}
files.set(to, bytes);
},
async readAsBase64(uri) {
const bytes = files.get(uri);
if (!bytes) {
throw new Error(`readAsBase64: file does not exist: ${uri}`);
}
return toBase64(bytes);
},
async delete(uri, deleteOptions) {
if (!files.delete(uri) && !deleteOptions.idempotent) {
throw new Error(`delete: file does not exist: ${uri}`);
}
},
async listDirectory(uri) {
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
const entries: string[] = [];
for (const path of files.keys()) {
if (path.startsWith(prefix)) {
entries.push(path.slice(prefix.length));
}
}
return entries;
},
};
}
function stripTrailingSlash(uri: string): string {
return uri.endsWith("/") ? uri.slice(0, -1) : uri;
}
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
if (typeof btoa === "function") {
return btoa(binary);
}
return Buffer.from(binary, "binary").toString("base64");
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -3,7 +3,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@server/shared/messages";
export function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
@@ -57,41 +57,33 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
}
describe("resolveClientSlashCommand", () => {
it("declares the exact canonical client commands with their aliases", () => {
expect(
CLIENT_SLASH_COMMANDS.map((command) => [
command.name,
[...command.aliases],
command.execution,
]),
).toEqual([
["exit", ["quit", "q"], "immediate"],
["clear", ["new"], "immediate"],
it("declares the exact client commands that execute immediately", () => {
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
["quit", "immediate"],
["exit", "immediate"],
["q", "immediate"],
["clear", "immediate"],
["new", "immediate"],
]);
});
it("resolves canonical names and aliases after trimming", () => {
it("resolves exact client commands after trimming", () => {
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
execution: "immediate",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
});
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
+22 -10
View File
@@ -6,7 +6,6 @@ export type ClientSlashCommandExecution = "immediate" | "insert";
export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
argumentHint: string;
kind: ClientSlashCommandKind;
@@ -14,9 +13,22 @@ export interface ClientSlashCommand {
}
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
{
name: "quit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "exit",
aliases: ["quit", "q"],
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "q",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
@@ -24,7 +36,13 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
{
name: "clear",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
{
name: "new",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
@@ -32,13 +50,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
];
const COMMAND_BY_NAME = new Map<string, ClientSlashCommand>();
for (const command of CLIENT_SLASH_COMMANDS) {
COMMAND_BY_NAME.set(command.name, command);
for (const alias of command.aliases) {
COMMAND_BY_NAME.set(alias, command);
}
}
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
export function resolveClientSlashCommand(input: {
text: string;
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
@@ -14,7 +14,7 @@ import {
} from "@gorhom/bottom-sheet";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -28,7 +28,9 @@ import { isNative, isWeb } from "@/constants/platform";
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch {
value: string;
onChange: (value: string) => void;
initialValue?: string;
resetKey?: string | number;
placeholder?: string;
autoFocus?: boolean;
@@ -165,7 +167,6 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
},
inlineTitle: {
flex: 1,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -201,22 +202,9 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
footer: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.surface2,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
@@ -249,43 +237,38 @@ export type AdaptiveTextInputProps = TextInputProps & {
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
//
// Text color and placeholder color are owned by this leaf — not the caller.
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
// under whatever theme is active at mount time, then keeps them mounted across
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
// ...)` from outside this leaf ends up with stale colors in dark mode (see
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
// TextInput so theme-driven re-renders land on the wrapper.
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
// Leaf-owned color goes LAST so callers cannot override it with a stale
// theme read. Outline color is theme-aware on web :focus-visible.
const {
value: _value,
initialValue,
resetKey,
defaultValue,
style,
placeholderTextColor,
...inputProps
} = props;
// Recolor the browser's :focus-visible outline (defined in public/index.html)
// so it matches the active theme's accent instead of its hard-coded fallback.
// Consumer style wins if it sets outlineColor explicitly.
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
style: [styles.adaptiveInputOutline, style],
};
if (isMobile && isNative) {
return (
<ThemedBottomSheetTextInput
<BottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
@@ -361,7 +344,10 @@ export function SheetHeaderView({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
@@ -378,7 +364,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
const hasInlineRow = Boolean(handleBackPress || header.leading);
if (!hasInlineRow && !header.search) return null;
return (
<View>
@@ -405,7 +391,6 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
</View>
) : null}
{header.search ? (
@@ -415,7 +400,10 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
@@ -433,8 +421,6 @@ export interface AdaptiveModalSheetProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -449,7 +435,6 @@ export function AdaptiveModalSheet({
visible,
onClose,
children,
footer,
snapPoints,
testID,
desktopMaxWidth,
@@ -515,7 +500,6 @@ export function AdaptiveModalSheet({
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -528,13 +512,13 @@ export function AdaptiveModalSheet({
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</>
);
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { submitAgentInput } from "./submit";
import { submitAgentInput } from "./agent-input-submit";
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
@@ -1,7 +1,7 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./model-loading";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
@@ -1,19 +1,17 @@
import { describe, expect, it } from "vitest";
import {
formatAgentModeLabel,
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHint,
formatThinkingOptionLabel,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
} from "./agent-status-bar.utils";
describe("getAgentControlHint", () => {
it("explains what each editable agent control does", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
expect(getAgentControlHint("model")).toBe("Change model");
expect(getAgentControlHint("mode")).toBe("Change permission mode");
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
});
});
@@ -54,36 +52,6 @@ describe("normalizeModelId", () => {
});
});
describe("formatAgentModeLabel", () => {
it("sentence-cases provider mode labels", () => {
expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan");
expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access");
expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review");
expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only");
expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits");
});
it("splits compact mode ids when no provider label is available", () => {
expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review");
});
});
describe("formatThinkingOptionLabel", () => {
it("formats compact thinking option labels for display", () => {
expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None");
expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low");
expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium");
expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high");
});
it("sentence-cases split provider labels", () => {
expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high");
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
});
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
@@ -129,28 +97,6 @@ describe("resolveAgentModelSelection", () => {
expect(selection.displayThinking).toBe("High");
});
it("formats raw thinking labels in the selected model display", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "claude",
label: "Model A",
thinkingOptions: [
{ id: "none", label: "none" },
{ id: "xhigh", label: "xhigh" },
],
},
],
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: "xhigh",
});
expect(selection.selectedThinkingId).toBe("xhigh");
expect(selection.displayThinking).toBe("Extra high");
});
it("falls back to the provider default model label instead of Auto", () => {
const selection = resolveAgentModelSelection({
models: [
@@ -1,9 +1,9 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getAgentControlHint(selector: ExplainedAgentControl): string {
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
case "thinking":
return "Thinking mode";
@@ -32,8 +32,6 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
switch (featureId) {
case "fast_mode":
return "yellow";
case "auto_accept":
return "green";
case "plan_mode":
return "blue";
default:
@@ -41,50 +39,6 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
interface ControlLabelInput {
id: string;
label?: string | null;
}
function sentenceCase(value: string): string {
if (!value) {
return value;
}
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function splitCompactLabel(value: string, splitHyphen: boolean): string {
const separatorPattern = splitHyphen ? /[_-]+/g : /_+/g;
return value
.replace(separatorPattern, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
}
function formatControlLabel(option: ControlLabelInput, splitHyphen: boolean): string {
const rawLabel = (option.label ?? option.id).trim();
return sentenceCase(splitCompactLabel(rawLabel, splitHyphen));
}
export function formatAgentModeLabel(mode: ControlLabelInput): string {
return formatControlLabel(mode, mode.label == null);
}
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high";
}
return formatControlLabel(option, true);
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
@@ -151,21 +105,6 @@ function resolveModelDisplay(
};
}
function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null,
): string {
if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking);
}
if (selectedThinkingId) {
return formatThinkingOptionLabel({ id: selectedThinkingId });
}
return "Unknown";
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -195,7 +134,7 @@ export function resolveAgentModelSelection(input: {
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
return {
selectedModel,
@@ -4,7 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
} from "@server/shared/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./model";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -5,9 +5,12 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export interface StreamRenderSegments {
historyVirtualized: StreamItem[];
@@ -19,6 +22,7 @@ export interface StreamHistoryBoundary {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
}
export interface StreamRenderAuxiliary {
@@ -204,6 +208,7 @@ export function buildAgentStreamRenderModel(
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};
@@ -3,9 +3,6 @@ import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborIndex,
getStreamNeighborItem,
@@ -13,8 +10,8 @@ import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:0${seed}.000Z`);
@@ -331,88 +328,3 @@ describe("edge slot semantics", () => {
});
});
});
describe("layout strategy edges", () => {
const streamItems: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
];
it("uses the newest history edge as the history/live boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: forward,
history: forwardHistory,
}),
).toBe(1);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
history: invertedHistory,
}),
).toBe(0);
});
it("uses the oldest live-head edge as the live-head/history boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
const forwardHead = orderHeadForStreamRenderStrategy({
strategy: forward,
streamHead: streamItems,
});
const invertedHead = orderHeadForStreamRenderStrategy({
strategy: inverted,
streamHead: streamItems,
});
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: forward,
liveHead: forwardHead,
}),
).toBe(0);
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
liveHead: invertedHead,
}),
).toBe(1);
});
it("names the frame child order needed by native inverted cells", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
"content-then-footer",
);
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
"footer-then-content",
);
});
});
@@ -0,0 +1,3 @@
export * from "./stream-strategy";
export * from "./stream-strategy-resolver";
export * from "./agent-stream-render-model";
@@ -8,10 +8,9 @@ import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -23,6 +22,52 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
function assistantBlock(params: {
id: string;
@@ -36,32 +36,44 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
} from "@server/server/agent/agent-sdk-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import {
CompletedTurnFooterRow,
resolveBottomTurnFooterHost,
shouldRenderCompletedTurnFooter,
TurnFooter,
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
} from "./use-bottom-anchor-controller";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -78,6 +90,12 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
@@ -115,39 +133,40 @@ function renderPendingPermissionsNode(input: {
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const footerHost = input.layoutItem.completedFooter;
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
/>
) : null;
const content = (
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
return (
<>
{footer}
{content}
</>
);
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
return (
<>
{content}
{footer}
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
</>
);
}
@@ -175,26 +194,47 @@ function renderListEmptyComponent(input: {
function renderHistoryStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
return null;
}
return input.renderStreamItem(layoutItem);
const seamBelowItem =
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
export interface AgentStreamViewHandle {
@@ -356,23 +396,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [agent.status, isMobile, streamHead, streamItems]);
const streamLayout = useMemo(
() =>
layoutStream({
strategy: streamRenderStrategy,
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
],
);
useImperativeHandle(
ref,
() => ({
@@ -409,28 +432,73 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
(
item: Extract<StreamItem, { kind: "user_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[streamRenderStrategy],
);
const renderAssistantMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
(
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seams.aboveItem ??
undefined;
const belowItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) ??
seams.belowItem ??
undefined;
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantFileLinkResolverProvider
client={client}
@@ -445,16 +513,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
spacing={spacing}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCallSlot
itemId={item.id}
@@ -462,16 +537,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={layoutItem.isLastInToolSequence}
isLastInSequence={isLastInSequence}
/>
);
},
[setInlineDetailsExpanded],
[streamRenderStrategy, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
@@ -497,7 +579,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
@@ -512,29 +594,33 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={layoutItem.isLastInToolSequence}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
switch (item.kind) {
case "user_message":
return renderUserMessageItem(layoutItem, item);
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
case "assistant_message":
return renderAssistantMessageItem(layoutItem, item);
return renderAssistantMessageItem(item, index, items, seams);
case "thought":
return renderThoughtItem(layoutItem, item);
return renderThoughtItem(item, index, items);
case "tool_call":
return renderToolCallItem(layoutItem, item);
return renderToolCallItem(item, index, items);
case "activity_log":
return (
@@ -565,18 +651,54 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
const bottomTurnFooterHost = useMemo(() => {
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
const renderStreamItem = useCallback(
(layoutItem: StreamLayoutItem) => {
const content = renderStreamItemContent(layoutItem);
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
return renderStreamItemWithTurnFooter({
content,
layoutItem,
item,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy,
});
},
[renderStreamItemContent, streamRenderStrategy],
[
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
);
const pendingPermissionItems = useMemo(
@@ -613,7 +735,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: baseRenderModel.boundary,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetweenStreamItems(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode,
@@ -627,32 +755,38 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderModel, emptyStateStyle],
);
const historyItems = renderModel.history;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const layoutHistoryItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.history) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.history]);
const layoutLiveHeadItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.liveHead) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.liveHead]);
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
item,
layoutItemById: layoutHistoryItemById,
historyIndexById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[layoutHistoryItemById, renderStreamItem],
[
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -663,13 +797,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderHistoryRow],
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item) =>
(item, index, items) =>
renderLiveHeadStreamItem({
item,
layoutItemById: layoutLiveHeadItemById,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[layoutLiveHeadItemById, renderStreamItem],
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({
@@ -13,7 +13,7 @@ import {
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./web-virtualization";
} from "./agent-stream-web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -11,19 +11,9 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Search,
Settings,
Star,
} from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -58,6 +48,7 @@ import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
@@ -88,7 +79,6 @@ interface CombinedModelSelectorProps {
onOpen?: () => void;
onClose?: () => void;
disabled?: boolean;
serverId?: string | null;
}
interface SelectorContentProps {
@@ -101,7 +91,6 @@ interface SelectorContentProps {
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
serverId: string | null;
}
function normalizeSearchQuery(value: string): string {
@@ -279,60 +268,42 @@ function FavoritesSection({
}
interface GroupProviderButtonProps {
provider: ProviderSelectorProvider;
providerId: string;
providerLabel: string;
rowCount: number;
defaultLabel: string | null;
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.rowIconButton,
Boolean(hovered) && styles.rowIconButtonHovered,
pressed && styles.rowIconButtonPressed,
];
}
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
defaultLabel,
onDrillDown,
onSelectDefault,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onDrillDown(provider.id, provider.label);
}, [onDrillDown, provider.id, provider.label]);
let stateNode: React.ReactNode;
if (selection.kind === "models") {
const count = selection.rows.length;
stateNode = (
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
);
} else if (selection.kind === "loading") {
stateNode = (
<View style={styles.rowStateInline}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.drillDownCount}>Loading</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text>
</View>
);
}
if (defaultLabel) {
onSelectDefault(providerId);
return;
}
onDrillDown(providerId, providerLabel);
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{provider.label}</Text>
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
{stateNode}
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
@@ -341,22 +312,64 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
function GroupedProviderRows({
providers,
onDrillDown,
onSelectDefault,
}: {
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{providers.map((provider, index) => (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
</View>
))}
{providers.map((provider, index) => {
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={provider.id}
providerLabel={provider.label}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
</View>
);
})}
</View>
);
}
function DefaultProviderRow({
providerId,
isSelected,
onSelect,
}: {
providerId: string;
isSelected: boolean;
onSelect: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onSelect(providerId, "");
}, [onSelect, providerId]);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Default"
selected={isSelected}
onPress={handlePress}
leadingSlot={leadingSlot}
/>
);
}
function ProviderModelRows({
rows,
selectedProvider,
@@ -417,31 +430,6 @@ function ProviderModelRows({
);
}
function ProviderErrorEmptyState({
serverId,
providerId,
message,
}: {
serverId: string | null;
providerId: string;
message: string;
}) {
const { theme } = useUnistyles();
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
const handleRetry = useCallback(() => {
void refresh([providerId]);
}, [refresh, providerId]);
return (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>{message}</Text>
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
{isRefreshing ? "Retrying…" : "Retry"}
</Button>
</View>
);
}
function SelectorContent({
view,
providers,
@@ -452,7 +440,6 @@ function SelectorContent({
onSelect,
onToggleFavorite,
onDrillDown,
serverId,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
@@ -474,6 +461,12 @@ function SelectorContent({
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const handleSelectDefaultProvider = useCallback(
(providerId: string) => {
onSelect(providerId, "");
},
[onSelect],
);
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
<View style={styles.emptyState}>
@@ -486,28 +479,17 @@ function SelectorContent({
if (!selectedViewProvider) {
return emptyState;
}
const drillSelection = selectedViewProvider.modelSelection;
if (drillSelection.kind === "loading") {
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
return (
<View style={styles.emptyState}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.emptyStateText}>Loading</Text>
</View>
);
}
if (drillSelection.kind === "error") {
return (
<ProviderErrorEmptyState
serverId={serverId}
<DefaultProviderRow
providerId={view.providerId}
message={drillSelection.message}
isSelected={view.providerId === selectedProvider && !selectedModel}
onSelect={onSelect}
/>
);
}
if (visibleRows.length === 0) {
return emptyState;
}
@@ -537,7 +519,11 @@ function SelectorContent({
/>
{providers.length > 0 ? (
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
<GroupedProviderRows
providers={providers}
onDrillDown={onDrillDown}
onSelectDefault={handleSelectDefaultProvider}
/>
) : null}
{!hasResults ? emptyState : null}
@@ -557,7 +543,6 @@ export function CombinedModelSelector({
onOpen,
onClose,
disabled = false,
serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
@@ -663,10 +648,6 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -692,42 +673,21 @@ export function CombinedModelSelector({
setSearchQuery(value);
}, []);
const openHeaderProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
handleClose();
}, [serverId, view, handleClose]);
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openHeaderProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
size={theme.iconSize.sm}
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
/>
</Pressable>
);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : undefined,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: headerActions,
search: {
value: searchQuery,
onChange: handleSearchQueryChange,
initialValue: searchQuery,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
autoFocus: platformIsWeb,
@@ -737,15 +697,11 @@ export function CombinedModelSelector({
}, [
view,
singleProviderView,
serverId,
openHeaderProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,
handleSearchQueryChange,
searchQuery,
searchResetKey,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
]);
@@ -804,7 +760,6 @@ export function CombinedModelSelector({
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onDrillDown={handleDrillDown}
serverId={serverId}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -902,34 +857,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
rowStateInline: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 1,
minWidth: 0,
},
rowErrorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
maxWidth: 140,
},
rowIconButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
rowSpinner: {
transform: [{ scale: 0.7 }],
},
rowIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
@@ -1,66 +0,0 @@
import { useCallback } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Heart } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { GitHubIcon } from "@/components/icons/github-icon";
import { DiscordIcon } from "@/components/icons/discord-icon";
import { openExternalUrl } from "@/utils/open-external-url";
const renderGitHubIcon = (color: string) => <GitHubIcon color={color} size={14} />;
const renderDiscordIcon = (color: string) => <DiscordIcon color={color} size={14} />;
export function CommunityLinks() {
const handleOpenGitHub = useCallback(() => {
void openExternalUrl("https://github.com/getpaseo/paseo");
}, []);
const handleOpenSponsor = useCallback(() => {
void openExternalUrl("https://github.com/sponsors/boudra");
}, []);
const handleOpenDiscord = useCallback(() => {
void openExternalUrl("https://discord.gg/jz8T2uahpH");
}, []);
return (
<View style={styles.row}>
<Button
variant="ghost"
size="sm"
leftIcon={renderGitHubIcon}
onPress={handleOpenGitHub}
testID="community-links-github-star"
>
Star
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={Heart}
onPress={handleOpenSponsor}
testID="community-links-sponsor"
>
Sponsor
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={renderDiscordIcon}
onPress={handleOpenDiscord}
testID="community-links-discord"
>
Community
</Button>
</View>
);
}
const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 0,
},
}));
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -26,7 +26,7 @@ import {
type ComposerSendClient,
type QueueWriter,
type QueuedComposerMessage,
} from "./actions";
} from "./composer-actions";
const imageMetadata: AttachmentMetadata = {
id: "img-1",
@@ -189,7 +189,6 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getTail: (agentId) => fake.tail.get(agentId),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
@@ -354,7 +353,6 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
});
it("appends to the existing head when one is present", async () => {
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -8,14 +8,8 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { generateMessageId, type StreamItem } from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
export interface QueuedComposerMessage {
@@ -55,7 +49,6 @@ export interface ComposerCancelClient {
}
export interface AgentStreamWriter {
getTail: (agentId: string) => StreamItem[] | undefined;
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
@@ -137,13 +130,14 @@ export async function dispatchComposerAgentMessage(
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
text: input.text,
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
});
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
@@ -155,29 +149,23 @@ export async function dispatchComposerAgentMessage(
function appendUserMessageToStream(
agentId: string,
userMessage: UserMessageItem,
userMessage: StreamItem,
stream: AgentStreamWriter,
): void {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
if (result.changedHead) {
const head = stream.getHead(agentId);
if (head && head.length > 0) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
});
}
if (result.changedTail) {
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
return;
}
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
}
export interface QueueComposerMessageInput {

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