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
paseo-ai[bot] 6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra 1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra 754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra 698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra 63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot] 1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra 9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot] fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra 57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra 0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot] b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
Mohamed Boudra e630e205db chore(release): cut 0.1.79-beta.2 2026-05-21 11:23:34 +07:00
Mohamed Boudra 8865f41d58 fix(app): unblock android APK build by aligning native deps
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.

Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.

Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
2026-05-21 11:19:52 +07:00
Mohamed Boudra b94527fe1f fix(app): show empty state in import sheet when no sessions match 2026-05-21 11:19:52 +07:00
Mohamed Boudra dee8f485de Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
2026-05-21 11:43:22 +08:00
Mohamed Boudra 28415b3542 docs(release): make betas silent, add release-beta/stable skills 2026-05-21 10:20:21 +07:00
Mohamed Boudra 3dd305082e docs(alternatives): add hero mockup to alternative comparison pages 2026-05-21 10:12:04 +07:00
Mohamed Boudra cae3deb6e7 fix(website): SEO canonicals, host redirects, sitemap, app noindex
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
  (www subdomain and http variants), killing the duplicate-page
  errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
  every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
  param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
  description under 160 chars, pad short descriptions on
  /privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
  Disallow-all robots.txt so the SPA shell stops polluting the
  crawl with H1-missing / no-OG / low-word-count warnings.
2026-05-21 10:07:41 +07:00
Mohamed Boudra ea36f0879f docs(website): rewrite agent pages for SEO and desktop coverage
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
2026-05-20 22:31:28 +07:00
Mohamed Boudra 3bca1a72e8 Create agents in worktrees with auto-archive (#1120)
* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
2026-05-20 22:41:15 +08:00
paseo-ai[bot] 68d88f0928 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 14:39:26 +00:00
Mohamed Boudra 8a69d66f8e chore(release): cut 0.1.79-beta.1 2026-05-20 21:35:34 +07:00
Mohamed Boudra c7872d968b docs(changelog): draft 0.1.79-beta.1 entry 2026-05-20 21:34:01 +07:00
Mohamed Boudra 8c4f5940d6 fix(server): launch Pi through the Windows-aware spawn helper (#1121) 2026-05-20 22:31:23 +08:00
paseo-ai[bot] 9b2b511abe fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 12:17:32 +00:00
Matteo Pietro Dazzi e58ea31d6d chore: upgrade vitest (#1091)
* chore: upgrade vitest

* chore: sync lock
2026-05-20 12:14:13 +00:00
吴天一 c88f6fb2c2 docs: update built-in provider references (#1105) 2026-05-20 09:45:38 +00:00
勤 a3a8527a1c Fix Dvorak paste shortcut handling
Fixes #1083
2026-05-20 17:34:49 +08:00
Mohamed Boudra f1b3e25344 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-20 16:24:58 +07:00
paseo-ai[bot] 18a1bdcf72 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 08:57:53 +00:00
Mohamed Boudra f933bf6b3a fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103)
* fix: stop leaking CSS rules from dynamic position styles

* Fix floating surfaces on web

* Stop diff controls growing Unistyles rules

* Use inline style escape hatch in diff pane

* Centralize dynamic style escape hatches

* Restore startup workspace navigation

* Upgrade Unistyles for web cleanup fix

* Update review style test for inline styles

* Use redirect for startup workspace restore

* Update startup redirect test
2026-05-20 16:55:00 +08:00
Mohamed Boudra 905a0985f9 Forward create agent env to provider launches (#1112) 2026-05-20 15:23:05 +08:00
Zexin Yuan ca913728d9 Add publicUseTls option to NixOS module (#1106) 2026-05-20 07:20:13 +00:00
Mohamed Boudra 0d495a9625 Fix startup workspace restore (#1111) 2026-05-20 07:00:29 +00:00
Chris BanesandCursor 3786cf3569 Wait for Cursor ACP slash commands before listing. (#1099)
cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:47:10 +08:00
Mohamed Boudra 8a6bdb2d01 Run daemon dev diagnostics on workers 2026-05-20 09:36:09 +07:00
137 changed files with 5846 additions and 1196 deletions
+11
View File
@@ -0,0 +1,11 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
+11
View File
@@ -0,0 +1,11 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/release-beta
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/release-stable
+46 -1
View File
@@ -1,5 +1,50 @@
# Changelog
## 0.1.79 - 2026-05-21
### Added
- **Pi has been revamped with first-class support**
- Runs through your installed Pi CLI, so your Pi extensions and configuration carry over
- Pi agents can call Paseo tools when you have the Pi MCP extension installed
- Import a Pi session you started in the terminal
- Copy Pi's resume command from any agent to continue the session in your terminal
- Windows: Pi sessions match correctly across symlinked and junctioned workspace paths
- **New home screen with quick tiles for adding a project, importing a session, setting up providers, and pairing a device**
- **Create an agent directly into a fresh worktree that auto-archives when the run finishes**
- **Set a custom system prompt that applies to every agent you start**
- **Rename workspaces, terminals, and agent tabs** ([#531](https://github.com/getpaseo/paseo/pull/531))
- **DeepSeek TUI in the ACP provider catalog** ([#1096](https://github.com/getpaseo/paseo/pull/1096))
- **Kiro CLI in the ACP provider catalog** (by [@huhusmang](https://github.com/huhusmang))
- Catalog providers show their icons in the model picker ([#1098](https://github.com/getpaseo/paseo/pull/1098))
- Custom environment variables passed when creating an agent now reach the agent process ([#1112](https://github.com/getpaseo/paseo/pull/1112))
- NixOS module supports the public TLS option for self-hosted relays ([#1106](https://github.com/getpaseo/paseo/pull/1106) by [@yzx9](https://github.com/yzx9))
### Improved
- **Stale host connections recover automatically without a manual refresh**
- Paseo opens to the workspace you were on last time you used it ([#1101](https://github.com/getpaseo/paseo/pull/1101))
- Workspaces remember which editor you opened them in
- Outdated daemons now suggest an upgrade when they receive a command they don't understand
- Voice mode is hidden while an agent is running
- Agent file-link tooltips show the full resolved file path ([#1088](https://github.com/getpaseo/paseo/pull/1088))
- Workspace git status refreshes less aggressively in the background ([#1102](https://github.com/getpaseo/paseo/pull/1102))
### Fixed
- macOS desktop no longer freezes after the display wakes from sleep ([#745](https://github.com/getpaseo/paseo/pull/745))
- Windows: Codex picks up the Microsoft Store install correctly ([#1020](https://github.com/getpaseo/paseo/pull/1020) by [@32r4](https://github.com/32r4))
- Workspace selection survives a daemon restart ([#1111](https://github.com/getpaseo/paseo/pull/1111))
- Cursor agents wait for slash commands to load before listing them ([#1099](https://github.com/getpaseo/paseo/pull/1099) by [@chrisbanes](https://github.com/chrisbanes))
- Codex sub-agents keep running through transient child process errors (by [@xy-plus](https://github.com/xy-plus))
- iPad terminals send Ctrl+C correctly from a hardware keyboard (by [@samatar26](https://github.com/samatar26))
- Git filenames with non-ASCII characters render correctly (by [@samatar26](https://github.com/samatar26))
- Paste shortcuts work on Dvorak keyboard layouts (by [@qin-nz](https://github.com/qin-nz))
- Claude file links resolve correctly for projects whose paths need SDK encoding
- Duplicate Claude result text no longer appears in chat ([#1095](https://github.com/getpaseo/paseo/pull/1095))
- Dynamic UI styles no longer leak CSS rules across the page ([#1103](https://github.com/getpaseo/paseo/pull/1103))
- Relay handshakes reject sessions that try to change encryption keys mid-flight ([#1037](https://github.com/getpaseo/paseo/pull/1037) by [@joaosa](https://github.com/joaosa))
## 0.1.78 - 2026-05-18
### Improved
@@ -680,7 +725,7 @@
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support
- Pi (pi.dev) agent provider — connect Pi as a new agent type with thinking levels and tool call support
- Copilot agent provider re-enabled after ACP compatibility fixes
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode
+1 -1
View File
@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
+4 -2
View File
@@ -19,7 +19,7 @@
</a>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -34,7 +34,7 @@
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -49,7 +49,9 @@ You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### Desktop app (recommended)
+2
View File
@@ -27,6 +27,8 @@ Both look the same in storage. This is an accepted limitation — see [Limitatio
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
+4
View File
@@ -38,6 +38,10 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash
+13 -10
View File
@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
│ (Node.js) │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode │
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
┌────────────┼────────────┬────────────┬────────────┐
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘
```
## Components at a glance
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -215,16 +217,17 @@ initializing → idle ⇄ running
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi-direct | Direct Anthropic API call | Stateless |
| Pi | Local Pi RPC process | Provider-managed |
| Mock load test | In-process fake | In-memory |
All providers:
+25 -7
View File
@@ -173,7 +173,25 @@ All fields are optional with sensible defaults.
---
## 3. Schedule
## 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.
---
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -221,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`
@@ -258,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -347,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`
@@ -370,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -389,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`
@@ -403,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.
+1 -1
View File
@@ -15,7 +15,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **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).
+4 -4
View File
@@ -16,7 +16,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
```
## Baseline
@@ -44,6 +44,6 @@ One live reasoning-dedup matrix run returned no reasoning content; an immediate
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`
+17 -15
View File
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
## Standard release (patch)
@@ -51,10 +51,11 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
@@ -264,18 +265,15 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
@@ -359,7 +357,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -375,15 +373,19 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
+8 -8
View File
@@ -102,14 +102,14 @@ When a test is labeled end-to-end, it calls the real service. No environment var
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| Suffix | What it is | Where it runs |
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — 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` |
| Suffix | What it is | Where it runs |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.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).
+28
View File
@@ -80,6 +80,34 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
```tsx
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const styles = StyleSheet.create({
thumb: {
position: "absolute",
},
});
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
```
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
+13
View File
@@ -124,6 +124,17 @@ in
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
publicUseTls = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Whether the public (client-facing) relay endpoint uses TLS.
When `null` (default), the daemon falls back to `relay.useTls`.
Override when the internal path is plain `ws://` behind a
TLS-terminating reverse proxy.
'';
};
};
inheritUserEnvironment = lib.mkOption {
@@ -253,6 +264,8 @@ in
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
+1 -1
View File
@@ -1 +1 @@
sha256-vNhmXjWV1wf17JHVX6tpTz4CKeCE7hpyBLj8nzPVUH0=
sha256-ipuV4cM3LUqMQ39LyB4X3ezz5UgxyHGEhpBOq7en2Jw=
+1820 -311
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.78",
"version": "0.1.79",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.78",
"version": "0.1.79",
"private": true,
"main": "index.ts",
"scripts": {
@@ -85,12 +85,12 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.33.8",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
@@ -105,7 +105,8 @@
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -115,7 +116,7 @@
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"vitest": "^4.1.6",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="robots" content="noindex,nofollow" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+4 -14
View File
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
@@ -23,7 +23,6 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
};
return {
navigateToWorkspaceMock: vi.fn(),
redirectMock: vi.fn(),
state: hoistedState,
};
@@ -51,7 +50,6 @@ vi.mock("@/screens/startup-splash-screen", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
@@ -72,7 +70,6 @@ describe("Index route startup navigation", () => {
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
navigateToWorkspaceMock.mockReset();
redirectMock.mockReset();
container = document.createElement("div");
@@ -108,7 +105,6 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
expect(redirectMock).not.toHaveBeenCalled();
});
@@ -118,11 +114,8 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
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 () => {
@@ -131,10 +124,7 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when no persisted workspace exists", async () => {
+9 -11
View File
@@ -7,11 +7,11 @@ import {
resolveStartupWorkspaceSelection,
} from "@/app/host-runtime-bootstrap";
import {
navigateToWorkspace,
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const isDesktop = shouldUseDesktopDaemon();
@@ -37,17 +37,15 @@ export default function Index() {
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
React.useEffect(() => {
if (!startupWorkspaceSelection) {
return;
}
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
currentPathname: pathname,
});
}, [pathname, startupWorkspaceSelection]);
if (startupWorkspaceSelection) {
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
return (
<Redirect
href={buildHostWorkspaceRoute(
startupWorkspaceSelection.serverId,
startupWorkspaceSelection.workspaceId,
)}
/>
);
}
if (redirectRoute) {
@@ -7,6 +7,7 @@ import {
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
type GetDirectorySuggestions,
} from "./resolver";
const CONTEXT: AssistantFileLinkContext = {
@@ -20,7 +21,7 @@ function resolvedSuggestions(
}
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
getDirectorySuggestions: ReturnType<typeof vi.fn>;
getDirectorySuggestions: GetDirectorySuggestions;
searches: Array<{
query: string;
cwd: string;
@@ -34,7 +35,7 @@ function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEn
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn(
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
async (input: {
query: string;
cwd: string;
@@ -192,7 +193,7 @@ describe("fetchDaemonResolution", () => {
});
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn(async () => {
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
throw new Error("daemon unavailable");
});
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
@@ -63,15 +64,15 @@ function createQueryClient(): QueryClient {
});
}
function createWrapper(input: {
client: TestClient;
openedFiles: OpenedFile[];
toast?: {
show: ReturnType<typeof vi.fn>;
copied: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
function createToast(): ToastApi {
return {
show: vi.fn<ToastApi["show"]>(),
copied: vi.fn<ToastApi["copied"]>(),
error: vi.fn<ToastApi["error"]>(),
};
}) {
}
function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; toast?: ToastApi }) {
const queryClient = createQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
const openWorkspaceFile = useCallback(
@@ -118,7 +119,7 @@ describe("useFileLink", () => {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
onOpenWorkspaceFile: () => {},
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
toast: createToast(),
},
children,
),
@@ -145,7 +146,7 @@ describe("useFileLink", () => {
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const toast = createToast();
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
@@ -8,7 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -106,6 +106,8 @@ function CommandCenterActionRow({
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
+36 -18
View File
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -95,20 +96,27 @@ export function DiffViewer({
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
() => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
() => [
styles.linesContainer,
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
const webVerticalContentStyle = React.useMemo(
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
[fillAvailableHeight],
);
if (!diffLines.length) {
return (
@@ -118,29 +126,39 @@ export function DiffViewer({
);
}
return (
const lines = (
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
);
const horizontalScroll = (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
{lines}
</ScrollView>
);
const content = (
<ScrollView
style={outerScrollStyle}
contentContainerStyle={styles.verticalContent}
contentContainerStyle={webVerticalContentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
</ScrollView>
{horizontalScroll}
</ScrollView>
);
return content;
}
const styles = StyleSheet.create((theme) => {
+5 -1
View File
@@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
@@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
@@ -61,9 +61,21 @@ vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
Inbox: icon("Inbox"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () =>
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
@@ -100,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
header,
children,
testID,
}: {
visible: boolean;
title: string;
header?: { title: string; actions?: ReactNode };
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{title}</h1>
<h1>{header?.title}</h1>
{header?.actions}
{children}
</section>
) : null,
@@ -146,6 +159,8 @@ interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
cwd?: string | null;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
@@ -168,15 +183,18 @@ function renderSheet(
},
});
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd={cwd}
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
/>
</QueryClientProvider>,
);
@@ -254,7 +272,7 @@ function createSnapshotEntry(
};
}
describe("WorkspaceImportSheet", () => {
describe("ImportSessionSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -420,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={visible}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
@@ -472,7 +490,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
@@ -508,7 +526,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
@@ -654,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
@@ -691,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
expect(screen.queryByTestId("import-session-filters")).toBeNull();
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
@@ -720,4 +738,119 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("No importable providers are enabled.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
title: "Cross-project session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ cwd: expect.anything() }),
);
await screen.findByText("/home/me/work/other-project");
});
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onImported = vi.fn();
const onImportedAgent = vi.fn();
const onClose = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
onClose,
onImported,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/home/me/work/other-project",
});
});
expect(onImported).toHaveBeenCalledTimes(1);
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("refetches sessions when the refresh button is clicked", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: "Refreshable session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Refreshable session");
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("import-session-refresh"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2);
});
});
});
@@ -4,6 +4,7 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -13,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -24,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
interface ImportSessionSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
cwd?: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void;
}
type RecentSessionsResponse = Awaited<
@@ -83,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
cwd: string | null | undefined;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
@@ -172,8 +175,6 @@ interface SheetStatusMessagesProps {
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
@@ -184,12 +185,10 @@ function SheetStatusMessages({
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
@@ -216,45 +215,117 @@ function SheetStatusMessages({
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
) : null}
{showEmptyState ? (
<Text style={styles.statusText}>
{allAlreadyImported
? "All recent sessions are already imported."
: "No recent sessions to import."}
</Text>
) : null}
</>
);
}
interface EmptyStateInputs {
isLoadingSessions: boolean;
allQueriesErrored: boolean;
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
providerLabelById: ReadonlyMap<string, string>;
}
function computeEmptyState(input: EmptyStateInputs): {
showEmptyState: boolean;
emptyStateTitle: string;
} {
const showEmptyState =
!input.isLoadingSessions &&
!input.allQueriesErrored &&
input.isQueryingProviders &&
input.allQueriesSettled &&
input.visibleCount === 0;
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function SheetEmptyState({ title }: { title: string }) {
const { theme } = useUnistyles();
return (
<View style={styles.emptyState} testID="import-session-empty-state">
<View style={styles.emptyStateIcon}>
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
</View>
<Text style={styles.emptyStateTitle}>{title}</Text>
</View>
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `workspace-import-filter-${provider}`,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function WorkspaceImportSheetRow({
function ImportSessionSheetRow({
entry,
disabled,
importing,
showCwd,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -285,7 +356,7 @@ function WorkspaceImportSheetRow({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -300,19 +371,25 @@ function WorkspaceImportSheetRow({
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
{showCwd && entry.cwd ? (
<Text style={styles.rowCwd} numberOfLines={1}>
{entry.cwd}
</Text>
) : null}
</View>
</Pressable>
);
}
export function WorkspaceImportSheet({
export function ImportSessionSheet({
visible,
client,
serverId,
workspaceDirectory,
cwd,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
@@ -330,8 +407,8 @@ export function WorkspaceImportSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const queriesConfig = useMemo(
@@ -341,9 +418,9 @@ export function WorkspaceImportSheet({
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
cwd,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
);
const queries = useQueries({ queries: queriesConfig });
@@ -379,20 +456,25 @@ export function WorkspaceImportSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
cwd: effectiveCwd,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
onImportedAgent?.(agent.id);
onImported?.(agent);
},
});
@@ -413,6 +495,20 @@ export function WorkspaceImportSheet({
[queries, providersToFetch, providerLabelById],
);
const isRefreshing = queries.some((query) => query.isFetching);
const handleRefresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
}, [queryClient, sessionsQueryRoot]);
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
@@ -423,21 +519,25 @@ export function WorkspaceImportSheet({
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const { showEmptyState, emptyStateTitle } = computeEmptyState({
isLoadingSessions,
allQueriesErrored,
isQueryingProviders,
allQueriesSettled,
selectedProvider,
aggregatedCount: aggregatedEntries.length,
visibleCount: visibleEntries.length,
totalAlreadyImportedCount,
providerLabelById,
});
const showFilter = filterProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
@@ -448,7 +548,7 @@ export function WorkspaceImportSheet({
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="workspace-import-filters"
testID="import-session-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
@@ -457,29 +557,29 @@ export function WorkspaceImportSheet({
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
showEmptyState={showEmptyState}
allAlreadyImported={allAlreadyImported}
/>
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
showCwd={!cwd}
onImportSession={handleImportSession}
/>
))}
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
);
}
@@ -539,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
rowCwd: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
@@ -549,4 +653,34 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[8],
paddingHorizontal: theme.spacing[4],
},
emptyStateIcon: {
opacity: 0.6,
marginBottom: theme.spacing[1],
},
emptyStateTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -79,7 +80,7 @@ function useDetailStyles(
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -88,7 +89,7 @@ function useDetailStyles(
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -97,7 +98,7 @@ function useDetailStyles(
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],
+12 -9
View File
@@ -20,6 +20,8 @@ import {
useWindowDimensions,
type LayoutChangeEvent,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -56,6 +58,7 @@ import {
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
const IS_WEB = isWeb;
@@ -858,7 +861,7 @@ interface DesktopContainerStyleInput {
availableHeight: number | undefined;
}
function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
@@ -877,7 +880,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
styles.desktopContainer,
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
@@ -1070,7 +1072,7 @@ interface DesktopBodyProps {
handleClose: () => void;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown;
desktopFrameStyle: StyleProp<ViewStyle>;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
@@ -1191,11 +1193,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
>
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
<Animated.View
<FloatingSurface
testID="combobox-desktop-container"
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={props.desktopContainerStyle as never}
style={styles.desktopContainer}
frameStyle={props.desktopFrameStyle}
ref={props.refs.setFloating}
collapsable={false}
onLayout={props.handleDesktopContentLayout}
@@ -1227,7 +1230,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
renderOption={props.renderOption}
/>
)}
</Animated.View>
</FloatingSurface>
</View>
</Modal>
);
@@ -1492,9 +1495,9 @@ export function Combobox({
[theme.colors.palette.zinc],
);
const desktopContainerStyle = useMemo(
const desktopFrameStyle = useMemo(
() =>
buildDesktopContainerStyle({
buildDesktopFrameStyle({
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
@@ -1561,7 +1564,7 @@ export function Combobox({
handleClose={handleClose}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle}
desktopFrameStyle={desktopFrameStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}
@@ -18,7 +18,6 @@ import {
Modal,
Platform,
Pressable,
ScrollView,
StatusBar,
Text,
View,
@@ -28,7 +27,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
@@ -38,6 +37,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -498,7 +498,7 @@ export function ContextMenuContent({
[],
);
const animatedContentStyle = useMemo(() => {
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -508,7 +508,6 @@ export function ContextMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -566,23 +565,24 @@ export function ContextMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
<FloatingSurface
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={animatedContentStyle}
style={styles.content}
frameStyle={frameStyle}
>
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</ScrollView>
</Animated.View>
</FloatingScrollView>
</FloatingSurface>
</View>
</Modal>
);
@@ -14,7 +14,6 @@ import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Text,
View,
Dimensions,
@@ -25,10 +24,10 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -172,42 +171,37 @@ function computePosition({
return { x, y, actualPlacement };
}
interface SharedDropdownContentProps {
collapsable: false;
testID?: string;
style: StyleProp<ViewStyle>;
}
function renderDropdownSurface(input: {
isWebSurface: boolean;
sharedProps: SharedDropdownContentProps;
frameStyle: StyleProp<ViewStyle>;
testID?: string;
surfaceStyle: StyleProp<ViewStyle>;
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
onExited: () => void;
}): ReactElement {
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const body = scrollable ? (
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{content}
</ScrollView>
</FloatingScrollView>
) : (
content
);
if (isWebSurface) {
return <View {...sharedProps}>{body}</View>;
}
return (
<Animated.View
{...sharedProps}
<FloatingSurface
collapsable={false}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
@@ -217,7 +211,7 @@ function renderDropdownSurface(input: {
})}
>
{body}
</Animated.View>
</FloatingSurface>
);
}
@@ -513,7 +507,8 @@ export function DropdownMenuContent({
[],
);
const contentStyle = useMemo(() => {
const surfaceStyle = styles.content;
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -523,7 +518,6 @@ export function DropdownMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -543,14 +537,6 @@ export function DropdownMenuContent({
actualPlacement,
align,
]);
const sharedContentProps = useMemo(
() => ({
collapsable: false as const,
testID,
style: contentStyle,
}),
[testID, contentStyle],
);
const scrollViewportStyle = useMemo(
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize, webScrollbarStyle],
@@ -583,8 +569,9 @@ export function DropdownMenuContent({
/>
{!closing
? renderDropdownSurface({
isWebSurface: isWeb,
sharedProps: sharedContentProps,
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
@@ -0,0 +1,63 @@
import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react";
import {
ScrollView,
StyleSheet,
type ScrollViewProps,
type StyleProp,
type View,
type ViewStyle,
} from "react-native";
import Animated from "react-native-reanimated";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FloatingSurfaceProps extends Omit<ComponentProps<typeof Animated.View>, "style"> {
frameStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
}
export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function FloatingSurface(
{ frameStyle, style, ...props },
ref,
): ReactElement {
const inlineFrameStyle = useMemo(() => {
const flattened = StyleSheet.flatten(frameStyle);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [frameStyle]);
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
});
export interface FloatingScrollViewProps {
bounces?: boolean;
children: ReactNode;
contentContainerStyle?: StyleProp<ViewStyle>;
keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"];
showsVerticalScrollIndicator?: boolean;
style?: StyleProp<ViewStyle>;
}
export function FloatingScrollView({
bounces,
children,
contentContainerStyle,
keyboardShouldPersistTaps,
showsVerticalScrollIndicator,
style,
}: FloatingScrollViewProps): ReactElement {
const inlineStyle = useMemo(() => {
const flattened = StyleSheet.flatten(style);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [style]);
return (
<ScrollView
bounces={bounces}
contentContainerStyle={contentContainerStyle}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
style={inlineStyle}
>
{children}
</ScrollView>
);
}
+12 -10
View File
@@ -25,9 +25,10 @@ import {
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
@@ -494,19 +495,18 @@ export function TooltipContent({
[],
);
const contentStyle = useMemo(
const frameStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, style, position?.x, position?.y],
[maxWidth, position?.x, position?.y],
);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -519,7 +519,7 @@ export function TooltipContent({
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -527,9 +527,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -544,7 +545,7 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -552,9 +553,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</Pressable>
</Modal>
);
@@ -1,17 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
interface WebPointerStyle {
cursor?: "grab" | "grabbing";
touchAction?: "none";
userSelect?: "none";
transitionProperty?: string;
transitionDuration?: string;
transitionTimingFunction?: string;
}
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
@@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
);
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function useWebDesktopScrollbarMetrics() {
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
@@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() {
};
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
@@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
onPanResponderGrant: (event: GestureResponderEvent) => {
const clientY = readClientY(event);
dragStartOffsetRef.current = normalizedOffsetRef.current;
if (clientY !== null) {
dragStartClientYRef.current = clientY;
}
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
@@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
{
top: 0,
inlineUnistylesStyle({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
@@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({
const handleStyle = useMemo(
() => [
styles.handle,
{
inlineUnistylesStyle({
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
@@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
@@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
top: 0,
},
}));
@@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
@@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
interface Rect {
@@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({
[],
);
const cardStyle = useMemo(
() => [
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -279,7 +276,8 @@ function WorkspaceHoverCardContent({
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
style={cardStyle}
style={styles.card}
frameStyle={frameStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
@@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</>
) : null}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingTop: theme.spacing[2],
width: HOVER_CARD_WIDTH,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
+7 -10
View File
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/git/policy";
@@ -110,18 +111,20 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
(Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled],
[primaryDisabled, theme.colors.surface2],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
(hovered || pressed || open) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
],
[],
[theme.colors.surface2],
);
return (
@@ -230,9 +233,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
@@ -258,9 +258,6 @@ const styles = StyleSheet.create((theme) => ({
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
iconButton: {
width: 32,
height: 32,
+81 -44
View File
@@ -78,6 +78,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
@@ -113,25 +114,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
}
function diffModeTriggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.diffModeTrigger,
Boolean(hovered) && styles.diffModeTriggerHovered,
(pressed || Boolean(open)) && styles.diffModeTriggerPressed,
];
}
function expandAllButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
}
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
@@ -274,7 +256,12 @@ function DiffGutterCell({
style?: StyleProp<ViewStyle>;
}) {
const containerStyle = useMemo(
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
() => [
styles.gutterCell,
lineTypeBackground(type),
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
[type, gutterWidth, style],
);
const textStyle = useMemo(
@@ -544,7 +531,10 @@ function InlineReviewThreadContent({
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
@@ -580,7 +570,11 @@ function InlineReviewGutterSpacer({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
() => [
styles.inlineReviewGutterSpacer,
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
style,
],
[gutterWidth, height, style],
);
if (height === 0) {
@@ -604,10 +598,13 @@ function InlineReviewRow({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
@@ -656,7 +653,10 @@ function SplitDiffColumn({
[showDivider],
);
const linesContainerRowStyle = useMemo(
() => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }],
() => [
styles.linesContainer,
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
],
[scrollWidth],
);
@@ -916,7 +916,10 @@ function DiffFileBody({
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const linesContainerRowStyle = useMemo(
() => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }],
() => [
styles.linesContainer,
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
],
[availableWidth],
);
@@ -1062,7 +1065,7 @@ interface GitDiffPaneProps {
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean },
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
interface DiffLayoutToggleGroupProps {
@@ -1436,14 +1439,30 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.expandAllButton,
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
surfaceColor: string,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
selected && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
(selected || Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
@@ -1494,32 +1513,50 @@ export function GitDiffPane({
handleLayoutChange("split");
}, [handleLayoutChange]);
const controlSurfaceColor = theme.colors.surface2;
const diffModeTriggerStyle = useMemo(
() => buildDiffModeTriggerStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
buildToggleButtonStyle(
changesPreferences.layout === "unified",
[styles.toggleButton, styles.toggleButtonGroupStart],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
buildToggleButtonStyle(
changesPreferences.layout === "split",
[styles.toggleButton, styles.toggleButtonGroupEnd],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const hideWhitespaceToggleStyle = useMemo(
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
() =>
buildToggleButtonStyle(
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
[wrapLines, controlSurfaceColor],
);
const expandAllToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const {
@@ -2046,7 +2083,7 @@ export function GitDiffPane({
allExpanded={allExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllButtonStyle}
expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
+33 -15
View File
@@ -10,7 +10,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildSettingsRoute } from "@/utils/host-routes";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
actionId?: string;
keywords: string[];
routeKind: "settings" | "none";
routeKind: "settings" | "home" | "none";
}
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
keywords: ["open", "project", "folder", "workspace", "repo"],
routeKind: "none",
},
{
id: "home",
title: "Home",
icon: "home",
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
routeKind: "home",
},
{
id: "settings",
title: "Settings",
@@ -89,7 +96,7 @@ export interface CommandCenterActionItem {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
route?: Href;
shortcutKeys?: ShortcutKey[][];
}
@@ -155,21 +162,32 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const actionItems = useMemo(() => {
if (!open) {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter((action) =>
matchesActionQuery(query, action),
).map<CommandCenterActionItem>((action) => ({
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route: action.routeKind === "settings" ? settingsRoute : undefined,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
}));
}, [open, query, settingsRoute, overrides]);
return COMMAND_CENTER_ACTIONS.filter((action) => {
if (action.routeKind === "home" && !homeRoute) return false;
return matchesActionQuery(query, action);
}).map<CommandCenterActionItem>((action) => {
let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute;
else if (action.routeKind === "home") route = homeRoute;
return {
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
};
});
}, [open, query, settingsRoute, homeRoute, overrides]);
const items = useMemo(() => {
if (!open) {
@@ -2,6 +2,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { useLoadOlderAgentHistory } from "./use-load-older-agent-history";
@@ -9,7 +10,19 @@ import { useLoadOlderAgentHistory } from "./use-load-older-agent-history";
const serverId = "server-1";
const agentId = "agent-1";
function makeClient(fetchAgentTimeline = vi.fn().mockResolvedValue(undefined)) {
type FetchAgentTimeline = (agentId: string, request: unknown) => Promise<void>;
function createToast(): ToastApi {
return {
show: vi.fn<ToastApi["show"]>(),
copied: vi.fn<ToastApi["copied"]>(),
error: vi.fn<ToastApi["error"]>(),
};
}
function makeClient(
fetchAgentTimeline: FetchAgentTimeline = vi.fn<FetchAgentTimeline>().mockResolvedValue(undefined),
) {
return { fetchAgentTimeline };
}
@@ -17,7 +30,7 @@ function initialize(input?: {
cursor?: { epoch: string; startSeq: number; endSeq: number };
hasOlder?: boolean;
inFlight?: boolean;
fetchAgentTimeline?: ReturnType<typeof vi.fn>;
fetchAgentTimeline?: FetchAgentTimeline;
}) {
const client = makeClient(input?.fetchAgentTimeline);
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -69,7 +82,7 @@ describe("useLoadOlderAgentHistory", () => {
});
it("dedupes concurrent older-page requests", () => {
const fetchAgentTimeline = vi.fn(
const fetchAgentTimeline = vi.fn<FetchAgentTimeline>(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 20);
@@ -120,9 +133,9 @@ describe("useLoadOlderAgentHistory", () => {
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: true,
fetchAgentTimeline: vi.fn().mockRejectedValue(error),
fetchAgentTimeline: vi.fn<FetchAgentTimeline>().mockRejectedValue(error),
});
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const toast = createToast();
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast }));
act(() => {
@@ -272,6 +272,12 @@ describe("keyboard-shortcuts", () => {
context: { isMac: false },
action: "sidebar.toggle.both",
},
{
name: "matches Dvorak logical Cmd+. to toggle both sidebars on macOS",
event: { key: ".", code: "KeyE", metaKey: true },
context: { isMac: true },
action: "sidebar.toggle.both",
},
{
name: "routes Mod+D to message-input action outside terminal",
event: { key: "d", code: "KeyD", metaKey: true },
@@ -390,6 +396,11 @@ describe("keyboard-shortcuts", () => {
event: { key: " ", code: "Space" },
context: { focusScope: "message-input" },
},
{
name: "keeps Dvorak Cmd+V available for paste in message input",
event: { key: "v", code: "Period", metaKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
];
it.each(nonMatchingCases)("$name", ({ event, context }) => {
@@ -1041,9 +1041,17 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): bo
return parseDigit(event) !== null;
}
if (combo.key !== undefined) {
const eventKey = event.key.toLowerCase();
if (eventKey === combo.key) return true;
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
return true;
}
return combo.codeFallback === true && event.code === combo.code;
}
const codeMatch = event.code === combo.code;
const keyMatch = combo.key !== undefined && event.key.toLowerCase() === combo.key.toLowerCase();
return codeMatch || keyMatch;
return codeMatch;
}
function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {
+29 -9
View File
@@ -3,6 +3,8 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
export interface KeyCombo {
code: string;
key?: string;
shiftedKey?: string;
codeFallback?: boolean;
meta?: true;
ctrl?: true;
alt?: true;
@@ -14,6 +16,8 @@ export interface KeyCombo {
interface KeyMapping {
code: string;
key?: string;
shiftedKey?: string;
codeFallback?: boolean;
}
const KEY_MAP: Record<string, KeyMapping> = {};
@@ -26,18 +30,28 @@ for (let i = 0; i < 26; i++) {
for (let i = 0; i <= 9; i++) {
KEY_MAP[String(i)] = { code: `Digit${i}`, key: String(i) };
}
KEY_MAP["1"].shiftedKey = "!";
KEY_MAP["2"].shiftedKey = "@";
KEY_MAP["3"].shiftedKey = "#";
KEY_MAP["4"].shiftedKey = "$";
KEY_MAP["5"].shiftedKey = "%";
KEY_MAP["6"].shiftedKey = "^";
KEY_MAP["7"].shiftedKey = "&";
KEY_MAP["8"].shiftedKey = "*";
KEY_MAP["9"].shiftedKey = "(";
KEY_MAP["0"].shiftedKey = ")";
KEY_MAP["Digit"] = { code: "Digit" };
KEY_MAP["\\"] = { code: "Backslash" };
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
KEY_MAP[","] = { code: "Comma", key: "," };
KEY_MAP["."] = { code: "Period", key: "." };
KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" };
KEY_MAP["\\"] = { code: "Backslash", key: "\\", shiftedKey: "|" };
KEY_MAP["["] = { code: "BracketLeft", key: "[", shiftedKey: "{" };
KEY_MAP["]"] = { code: "BracketRight", key: "]", shiftedKey: "}" };
KEY_MAP[","] = { code: "Comma", key: ",", shiftedKey: "<" };
KEY_MAP["."] = { code: "Period", key: ".", shiftedKey: ">" };
KEY_MAP["`"] = { code: "Backquote", key: "`", shiftedKey: "~" };
KEY_MAP["/"] = { code: "Slash", key: "/", shiftedKey: "?" };
KEY_MAP["?"] = { code: "Slash", key: "?" };
KEY_MAP["Space"] = { code: "Space", key: " " };
KEY_MAP["Enter"] = { code: "Enter", key: "Enter" };
KEY_MAP["Space"] = { code: "Space", key: " ", codeFallback: true };
KEY_MAP["Enter"] = { code: "Enter", key: "Enter", codeFallback: true };
KEY_MAP["Backspace"] = { code: "Backspace" };
KEY_MAP["Escape"] = { code: "Escape" };
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
@@ -110,6 +124,12 @@ export function parseShortcutString(s: string): KeyCombo {
combo.code = mapping.code;
if (mapping.key !== undefined) {
combo.key = mapping.key;
if (mapping.shiftedKey !== undefined) {
combo.shiftedKey = mapping.shiftedKey;
}
if (mapping.codeFallback === true) {
combo.codeFallback = true;
}
}
return combo;
+2 -1
View File
@@ -3,6 +3,7 @@ import "@/test/window-local-storage";
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
@@ -275,7 +276,7 @@ describe("git diff inline review helpers", () => {
viewportWidth: 320,
pinToViewport: true,
}),
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
});
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {
+4 -2
View File
@@ -15,6 +15,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
@@ -391,7 +392,7 @@ export function InlineReviewThread({
() => [
styles.threadContainer,
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
{ minHeight: height },
inlineUnistylesStyle({ minHeight: height }),
],
[viewportWidth, pinToViewport, height],
);
@@ -478,7 +479,8 @@ export function getInlineReviewThreadViewportStyle({
viewportWidth?: number;
pinToViewport: boolean;
}): StyleProp<ViewStyle> {
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
const widthStyle =
viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null;
if (!pinToViewport || !isWeb) {
return widthStyle;
}
@@ -76,6 +76,10 @@ class FakeDaemonClient {
return { rttMs: 0 };
}
async checkLiveness(): Promise<{ rttMs: number }> {
return this.ping();
}
setReconnectEnabled(_enabled: boolean): void {}
setConnectionState(next: ConnectionState): void {
@@ -620,6 +624,85 @@ describe("HostRuntimeController", () => {
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
});
it("uses liveness probes instead of session RPC timeouts for active connection health", async () => {
const relay: HostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test",
};
const host = makeHost({
connections: [relay],
preferredConnectionId: relay.id,
});
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: makeDeps({ [relay.id]: 12 }, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().connectionStatus).toBe("online");
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
activeClient.ping = async () => {
throw new Error("Timeout waiting for message (5000ms)");
};
activeClient.checkLiveness = async () => ({ rttMs: 11 });
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.probeByConnectionId.get(relay.id)).toEqual({
status: "available",
latencyMs: 11,
});
expect(snapshot.connectionStatus).toBe("online");
});
it("backs off inactive connection probes while a host is online", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 10,
"relay:relay.paseo.sh:443": 50,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
const initialClientCount = clients.length;
const initialRelayProbe = controller
.getSnapshot()
.probeByConnectionId.get("relay:relay.paseo.sh:443");
latencies["direct:lan:6767"] = 12;
latencies["relay:relay.paseo.sh:443"] = 25;
const lastProbedAt = (
controller as unknown as {
connectionLastProbedAt: Map<string, number>;
}
).connectionLastProbedAt;
const now = performance.now();
lastProbedAt.set("direct:lan:6767", now - 60_000);
lastProbedAt.set("relay:relay.paseo.sh:443", now - 60_000);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(clients.length).toBe(initialClientCount);
expect(snapshot.probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "available",
latencyMs: 12,
});
expect(snapshot.probeByConnectionId.get("relay:relay.paseo.sh:443")).toEqual(initialRelayProbe);
});
it("switches only after the faster alternative wins consecutive probes", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
+13 -2
View File
@@ -142,6 +142,7 @@ export interface HostRuntimeStartOptions {
const PROBE_TICK_MS = 2_000;
const PROBE_STEADY_MS = 10_000;
const PROBE_MAX_BACKOFF_MS = 30_000;
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
@@ -434,11 +435,15 @@ function findConnectionById(host: HostProfile, connectionId: string | null): Hos
function probeIntervalForConnection(
firstSeenAt: number,
isActiveOnline: boolean,
hasActiveOnlineConnection: boolean,
now: number,
): number {
if (isActiveOnline) {
return PROBE_STEADY_MS;
}
if (hasActiveOnlineConnection) {
return PROBE_INACTIVE_WHILE_ONLINE_MS;
}
const age = now - firstSeenAt;
if (age < 10_000) return 2_000;
if (age < 30_000) return 5_000;
@@ -702,6 +707,7 @@ export class HostRuntimeController {
const now = performance.now();
const isOnline = this.snapshot.connectionStatus === "online";
const activeConnectionId = this.snapshot.activeConnectionId;
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
const connectionsToProbe = this.host.connections.filter((connection) => {
const lastProbed = this.connectionLastProbedAt.get(connection.id);
@@ -710,7 +716,12 @@ export class HostRuntimeController {
}
const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now;
const isActiveOnline = isOnline && connection.id === activeConnectionId;
const interval = probeIntervalForConnection(firstSeen, isActiveOnline, now);
const interval = probeIntervalForConnection(
firstSeen,
isActiveOnline,
hasActiveOnlineConnection,
now,
);
return now - lastProbed >= interval;
});
@@ -899,7 +910,7 @@ export class HostRuntimeController {
const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
const { rttMs } = await connectedClient.checkLiveness({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
}
+144 -47
View File
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { FolderOpen, Smartphone } from "lucide-react-native";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useHasWorkspaces } from "@/stores/session-store-hooks";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
@@ -18,14 +16,21 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useHasWorkspaces(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
const isCompactLayout = useIsCompactFormFactor();
@@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
})();
},
[openProject, router, serverId],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId));
}, [router, serverId]);
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={56} />
<PaseoLogo size={52} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button
variant="default"
leftIcon={FolderOpen}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title="Add a project"
description="Open a folder on your machine"
onPress={handleOpenPicker}
testID="open-project-submit"
>
Add a project
</Button>
accent
/>
<HomeTile
icon={Inbox}
title="Import session"
description="Bring in recent external CLI sessions"
onPress={handleOpenImportSession}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title="Setup providers"
description="Configure Claude Code, Codex, and more"
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
<Button
variant="outline"
leftIcon={Smartphone}
<HomeTile
icon={Smartphone}
title="Pair device"
description="Connect your phone to this daemon"
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
>
Pair device
</Button>
/>
) : null}
</View>
</View>
@@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
</View>
);
}
interface HomeTileProps {
icon: ComponentType<{ size: number; color: string }>;
title: string;
description: string;
onPress: () => void;
testID?: string;
accent?: boolean;
}
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
const { theme } = useUnistyles();
const [hovered, setHovered] = useState(false);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.tile,
hovered && styles.tileHovered,
pressed && styles.tilePressed,
],
[hovered],
);
return (
<Pressable
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={testID}
style={pressableStyle}
>
<Icon size={20} color={iconColor} />
<View style={styles.tileText}>
<Text style={styles.tileTitle}>{title}</Text>
<Text style={styles.tileDescription}>{description}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
justifyContent: "center",
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
@@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
tiles: {
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
width: "100%",
maxWidth: 452,
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
tile: {
width: { xs: "100%", md: 220 },
minHeight: { xs: 0, md: 132 },
padding: theme.spacing[4],
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
gap: theme.spacing[3],
},
heading: {
tileHovered: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.borderAccent,
},
tilePressed: {
opacity: 0.85,
},
tileText: {
gap: theme.spacing[1],
},
tileTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontWeight: theme.fontWeight.normal,
},
tileDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
}));
@@ -59,7 +59,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
<WorkspaceImportSheet
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
workspaceDirectory={workspaceDirectory}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
@@ -0,0 +1,23 @@
const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
/**
* Forces a style object through Unistyles' inline/animated-style lane.
*
* Unistyles web sends ordinary style objects to the CSS registry. Styles that
* look like animated styles stay in React Native's style array instead. Use
* this only for high-churn values, such as measured dimensions, drag
* transforms, and pressed/hovered state.
*/
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
return style;
}
Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
value: {},
enumerable: true,
configurable: true,
});
return style;
}
+5 -3
View File
@@ -135,6 +135,8 @@ function queryRowIds(): string[] {
).map((node) => node.getAttribute("data-testid")?.replace("subagents-section-row-", "") ?? "");
}
type SubagentAction = (id: string) => void;
describe("SubagentsSection", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
@@ -169,9 +171,9 @@ describe("SubagentsSection", () => {
function render(
rows: SubagentRow[],
onOpenSubagent: ReturnType<typeof vi.fn> = vi.fn(),
onArchiveSubagent: ReturnType<typeof vi.fn> = vi.fn(),
): ReturnType<typeof vi.fn> {
onOpenSubagent: SubagentAction = vi.fn<SubagentAction>(),
onArchiveSubagent: SubagentAction = vi.fn<SubagentAction>(),
): SubagentAction {
act(() => {
root?.render(
<SubagentsSection
@@ -17,6 +17,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
claude: {
resume: "claude --resume {sessionId}",
},
pi: {
resume: "pi --session {sessionId}",
},
};
function renderTemplate(template: string, vars: Record<string, string>): string {
+3 -6
View File
@@ -1,4 +1,5 @@
import { defineConfig, configDefaults } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
@@ -33,7 +34,7 @@ export default defineConfig({
include: ["src/**/*.browser.{test,spec}.{ts,tsx}"],
browser: {
enabled: true,
provider: "playwright",
provider: playwright(),
headless: true,
connectTimeout: 180_000,
instances: [{ browser: "chromium" }],
@@ -49,11 +50,7 @@ export default defineConfig({
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
*/
pool: "forks",
poolOptions: {
forks: {
maxForks: 2,
},
},
maxWorkers: 2,
server: {
deps: {
fallbackCJS: true,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.78",
"version": "0.1.79",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.78",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37,7 +37,7 @@
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^3.2.4",
"vitest": "^4.1.6",
"zx": "^8.8.5"
}
}
+42 -5
View File
@@ -46,6 +46,12 @@ export function addRunOptions(cmd: Command): Command {
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--env <key=value>",
"Set environment variable(s) for the agent process (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
@@ -95,6 +101,7 @@ export interface AgentRunOptions extends CommandOptions {
base?: string;
image?: string[];
cwd?: string;
env?: string[];
label?: string[];
waitTimeout?: string;
outputSchema?: string;
@@ -320,15 +327,41 @@ function loadRunImages(
}
function parseRunLabels(labelFlags: string[] | undefined): Record<string, string> {
return parseKeyValueFlags(labelFlags, {
flagName: "--label",
code: "INVALID_LABEL",
noun: "label",
pluralNoun: "Labels",
});
}
function parseRunEnv(envFlags: string[] | undefined): Record<string, string> {
return parseKeyValueFlags(envFlags, {
flagName: "--env",
code: "INVALID_ENV",
noun: "environment variable",
pluralNoun: "Environment variables",
});
}
function parseKeyValueFlags(
flags: string[] | undefined,
options: {
flagName: string;
code: CommandError["code"];
noun: string;
pluralNoun: string;
},
): Record<string, string> {
const labels: Record<string, string> = {};
if (!labelFlags) return labels;
for (const labelStr of labelFlags) {
if (!flags) return labels;
for (const labelStr of flags) {
const eqIndex = labelStr.indexOf("=");
if (eqIndex === -1) {
throw {
code: "INVALID_LABEL",
message: `Invalid label format: ${labelStr}`,
details: "Labels must be in key=value format",
code: options.code,
message: `Invalid ${options.noun} format: ${labelStr}`,
details: `${options.pluralNoun} must be in key=value format`,
} satisfies CommandError;
}
const key = labelStr.slice(0, eqIndex);
@@ -394,6 +427,8 @@ export async function runRunCommand(
: undefined;
const labels = parseRunLabels(options.label);
const env = parseRunEnv(options.env);
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
if (outputSchema) {
let structuredAgent: AgentSnapshotPayload | null = null;
@@ -410,6 +445,7 @@ export async function runRunCommand(
initialPrompt: structuredPrompt,
outputSchema,
images,
env: requestEnv,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
@@ -479,6 +515,7 @@ export async function runRunCommand(
thinkingOptionId,
initialPrompt: prompt,
images,
env: requestEnv,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.78",
"version": "0.1.79",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -37,7 +37,7 @@
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^3.2.4",
"vitest": "^4.1.6",
"wait-on": "8.0.5"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.78",
"version": "0.1.79",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.78",
"version": "0.1.79",
"files": [
"dist"
],
@@ -43,6 +43,6 @@
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^4.1.6"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.78",
"version": "0.1.79",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
@@ -46,6 +46,6 @@
"@types/node": "^20.9.0",
"@types/ws": "^8.5.8",
"typescript": "^5.2.2",
"vitest": "^3.2.4"
"vitest": "^4.1.6"
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ For AI coding agents working in `packages/server`. Supplements [CLAUDE.md](../CL
## Project Overview
Paseo is a mobile + CLI app for monitoring and controlling local AI coding agents (Claude Code, Codex, OpenCode). The daemon runs on your machine, manages agent processes, and streams their output over WebSocket to clients.
Paseo is a mobile + CLI app for monitoring and controlling local AI coding agents (Claude Code, Codex, GitHub Copilot, OpenCode, Pi). The daemon runs on your machine, manages agent processes, and streams their output over WebSocket to clients.
---
@@ -166,7 +166,7 @@ packages/server/src/
│ └── agent/
│ ├── agent-manager.ts # Agent lifecycle state machine
│ └── agent-storage.ts # File-backed JSON persistence
├── providers/ # Claude, Codex, OpenCode adapters
├── providers/ # Claude, Codex, Copilot, OpenCode, Pi adapters
├── relay-transport.ts # Outbound relay connection
└── client/daemon-client.ts # Client library for daemon connection
```
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.78",
"version": "0.1.79",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -48,14 +48,14 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
"test:integration:local": "vitest run local.e2e.test.ts",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
"test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1",
"test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
"test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1",
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:ui": "vitest --ui e2e.test.ts"
@@ -63,8 +63,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -78,7 +78,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -86,8 +85,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -103,11 +100,14 @@
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^3.2.4"
"vitest": "^4.1.6"
},
"overrides": {
"lightningcss": "1.30.1"
+1 -14
View File
@@ -8,21 +8,8 @@ dotenv.config({
});
const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url));
const inspectArg = process.env.PASEO_NODE_INSPECT ?? "--inspect";
const inspectArgs =
inspectArg === "0" || inspectArg === "false" || inspectArg === "off" ? [] : [inspectArg];
const supervisorArgs = [
...inspectArgs,
"--heapsnapshot-near-heap-limit=3",
"--max-old-space-size=3072",
"--report-on-fatalerror",
"--report-directory=/tmp/paseo-reports",
...process.execArgv,
daemonRunnerEntry,
"--dev",
...process.argv.slice(2),
];
const supervisorArgs = [...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)];
const supervisor = spawn(process.execPath, supervisorArgs, {
stdio: "inherit",
@@ -58,8 +58,22 @@ function resolveDevWorkerEntry(): string {
return candidate;
}
function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
function resolveWorkerExecArgv(workerEntry: string, devMode: boolean): string[] {
const execArgv = workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
if (!devMode) {
return execArgv;
}
const devArgs = [
"--heapsnapshot-near-heap-limit=3",
"--max-old-space-size=3072",
"--report-on-fatalerror",
"--report-directory=/tmp/paseo-reports",
];
const inspectArg = process.env.PASEO_NODE_INSPECT ?? "--inspect";
if (inspectArg !== "0" && inspectArg !== "false" && inspectArg !== "off") {
devArgs.push(inspectArg);
}
return [...devArgs, ...execArgv];
}
function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): string | null {
@@ -77,7 +91,7 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerExecArgv = resolveWorkerExecArgv(workerEntry, config.devMode);
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
@@ -281,6 +281,84 @@ test("does not reconnect after close when ensureConnected is called", async () =
expect(client.getConnectionState().status).toBe("disposed");
});
test("keeps the transport connected when a session RPC ping times out", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await expect(client.ping({ timeoutMs: 1 })).rejects.toThrow("Timeout waiting for message");
expect(client.getConnectionState().status).toBe("connected");
});
test("reconnects after repeated top-level liveness checks time out", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState().status).toBe("connected");
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState()).toEqual({
status: "disconnected",
reason: "Liveness check timed out (1ms)",
});
});
test("resets liveness failures after a top-level pong", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
const healthyProbe = client.checkLiveness({ timeoutMs: 100 });
mock.triggerMessage(JSON.stringify({ type: "pong" }));
await expect(healthyProbe).resolves.toEqual({ rttMs: expect.any(Number) });
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState().status).toBe("connected");
});
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -608,6 +686,62 @@ test("sends create_agent_request with string workspace ids", async () => {
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("sends structured attachments with create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+116
View File
@@ -249,6 +249,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
env?: CreateAgentRequestMessage["env"];
workspaceId?: string;
initialPrompt?: string;
clientMessageId?: string;
@@ -256,6 +257,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
@@ -718,6 +721,8 @@ class DaemonRpcError extends Error {
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
/** Default timeout for waiting for connection before sending queued messages */
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000;
@@ -824,6 +829,14 @@ interface PendingSend {
timeoutHandle: ReturnType<typeof setTimeout>;
}
interface LivenessProbe {
promise: Promise<{ rttMs: number }>;
resolve: (value: { rttMs: number }) => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
startedAt: number;
}
export class DaemonClient {
private transport: DaemonTransport | null = null;
private transportCleanup: Array<() => void> = [];
@@ -867,6 +880,8 @@ export class DaemonClient {
private lastServerInfoMessage: ServerInfoStatusPayload | null = null;
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
private livenessProbe: LivenessProbe | null = null;
private consecutiveLivenessFailures = 0;
constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger;
@@ -1128,6 +1143,7 @@ export class DaemonClient {
this.disposeTransport(1000, "Client closed");
this.clearWaiters(new Error("Daemon client closed"));
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.rejectLivenessProbe(new Error("Daemon client closed"));
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
if (this.runtimeMetricsInterval) {
@@ -1563,6 +1579,54 @@ export class DaemonClient {
};
}
checkLiveness(params?: { timeoutMs?: number }): Promise<{ rttMs: number }> {
if (this.connectionState.status !== "connected" || !this.transport) {
return Promise.reject(
new Error(`Transport not connected (status: ${this.connectionState.status})`),
);
}
if (this.livenessProbe) {
return this.livenessProbe.promise;
}
const startedAt = perfNow();
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
let resolveProbe: ((value: { rttMs: number }) => void) | null = null;
let rejectProbe: ((error: Error) => void) | null = null;
const promise = new Promise<{ rttMs: number }>((resolve, reject) => {
resolveProbe = resolve;
rejectProbe = reject;
});
const probe: LivenessProbe = {
promise,
resolve: (value) => resolveProbe?.(value),
reject: (error) => rejectProbe?.(error),
timeoutHandle: setTimeout(() => {
if (this.livenessProbe !== probe) {
return;
}
this.livenessProbe = null;
const error = new Error(`Liveness check timed out (${timeoutMs}ms)`);
probe.reject(error);
this.recordLivenessFailure(error);
}, timeoutMs),
startedAt,
};
this.livenessProbe = probe;
try {
this.transport.send(JSON.stringify({ type: "ping" }));
} catch (error) {
this.clearLivenessProbe();
const err = error instanceof Error ? error : new Error(String(error));
this.recordLivenessFailure(err);
return Promise.reject(err);
}
return promise;
}
// ============================================================================
// Agent RPCs (requestId-correlated)
// ============================================================================
@@ -1837,6 +1901,7 @@ export class DaemonClient {
type: "create_agent_request",
requestId,
config,
...(options.env ? { env: options.env } : {}),
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
@@ -1846,6 +1911,8 @@ export class DaemonClient {
? { attachments: options.attachments }
: {}),
...(options.git ? { git: options.git } : {}),
...(options.worktree ? { worktree: options.worktree } : {}),
...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
...(options.labels && Object.keys(options.labels).length > 0
? { labels: options.labels }
@@ -4261,7 +4328,10 @@ export class DaemonClient {
return;
}
this.consecutiveLivenessFailures = 0;
if (parsed.data.type === "pong") {
this.resolveLivenessProbe();
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
return;
}
@@ -4410,6 +4480,7 @@ export class DaemonClient {
// and responses from the previous connection will never arrive.
this.clearWaiters(new Error(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
this.rejectLivenessProbe(new Error(reason ?? "Connection lost"));
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
@@ -4458,6 +4529,50 @@ export class DaemonClient {
}, delay);
}
private resolveLivenessProbe(): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
probe.resolve({ rttMs: perfNow() - probe.startedAt });
}
private clearLivenessProbe(): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
}
private rejectLivenessProbe(error: Error): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
probe.reject(error);
}
private recordLivenessFailure(error: Error): void {
this.consecutiveLivenessFailures += 1;
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
return;
}
this.consecutiveLivenessFailures = 0;
this.lastErrorValue = error.message;
this.disposeTransport(1001, "Liveness check timed out");
this.scheduleReconnect({
reason: error.message,
event: "LIVENESS_TIMEOUT",
reasonCode: "liveness_timeout",
});
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "status") {
const serverInfo = parseServerInfoStatusPayload(msg.payload);
@@ -4667,6 +4782,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
config,
provider,
cwd,
env: _env,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
images: _images,
@@ -1,4 +1,5 @@
import { expect, test, vi } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -147,6 +148,46 @@ class TestAgentClient implements AgentClient {
}
}
class EnvProbeAgentClient extends TestAgentClient {
probe: Promise<{ probe: string | null; agentId: string | null }> | null = null;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const script = `
process.stdout.write(JSON.stringify({
probe: process.env.CHUNK14_PROBE ?? null,
agentId: process.env.PASEO_AGENT_ID ?? null
}));
`;
const child = spawn(process.execPath, ["-e", script], {
cwd: config.cwd,
env: { ...process.env, ...launchContext?.env },
stdio: ["ignore", "pipe", "pipe"],
});
this.probe = new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`env probe exited ${code}: ${stderr}`));
return;
}
resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null });
});
});
return new TestAgentSession(config);
}
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
@@ -373,6 +414,40 @@ test("normalizeConfig injects the provider default model when omitted", async ()
expect(snapshot.config.modeId).toBe("auto");
});
test("createAgent forwards request env into the spawned provider process", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-"));
const client = new EnvProbeAgentClient();
const manager = new AgentManager({
clients: {
codex: client,
},
logger,
idFactory: () => "00000000-0000-4000-8000-00000000e001",
});
try {
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{
env: {
CHUNK14_PROBE: "expected",
},
},
);
await expect(client.probe).resolves.toEqual({
probe: "expected",
agentId: "00000000-0000-4000-8000-00000000e001",
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("normalizeConfig strips legacy 'default' model id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -805,6 +805,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
},
): Promise<ManagedAgent> {
@@ -826,7 +827,7 @@ export class AgentManager {
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(injectedConfig),
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
});
@@ -3469,10 +3470,11 @@ export class AgentManager {
: next;
}
private buildLaunchContext(agentId: string): AgentLaunchContext {
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
return {
agentId,
env: {
...env,
PASEO_AGENT_ID: agentId,
},
};
@@ -0,0 +1,227 @@
import { randomUUID } from "node:crypto";
import type pino from "pino";
import type { GitHubService } from "../../services/github-service.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import { archivePaseoWorktree } from "../paseo-worktree-archive-service.js";
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../worktree-session.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type {
CreateAgentWorktreeTarget,
FirstAgentContext,
SessionOutboundMessage,
} from "../messages.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
interface CreateAgentLifecycleDispatchDependencies {
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
github: GitHubService;
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
logger: pino.Logger;
}
export class CreateAgentLifecycleDispatch {
private readonly autoArchiveAgentIds = new Set<string>();
constructor(private readonly dependencies: CreateAgentLifecycleDispatchDependencies) {}
async createWorktreeForRequest(input: {
cwd: string;
target: CreateAgentWorktreeTarget | undefined;
firstAgentContext: FirstAgentContext;
hasLegacyGitOptions: boolean;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
if (input.target && input.hasLegacyGitOptions) {
throw new Error("create_agent_request worktree cannot be combined with git options");
}
if (!input.target) {
return null;
}
return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext);
}
registerAutoArchiveIfRequested(input: {
autoArchive: boolean | undefined;
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}): void {
if (input.autoArchive !== true) {
return;
}
this.registerAutoArchiveOnTerminalState(input.agentId, {
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
repoRoot: input.createdWorktree?.repoRoot ?? null,
});
}
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
createdAgentId: string | null;
}): Promise<void> {
const { createdWorktree, createdAgentId } = input;
if (!createdWorktree || createdAgentId) {
return;
}
await this.archiveAutoCreatedWorktree({
agentId: null,
worktreePath: createdWorktree.worktree.worktreePath,
repoRoot: createdWorktree.repoRoot,
}).catch((archiveError) => {
this.dependencies.logger.warn(
{
err: archiveError,
worktreePath: createdWorktree.worktree.worktreePath,
},
"Failed to clean up worktree after create_agent_request failed",
);
});
}
private async createWorktreeForTarget(
cwd: string,
target: CreateAgentWorktreeTarget,
firstAgentContext: FirstAgentContext,
): Promise<CreatePaseoWorktreeWorkflowResult> {
const baseInput = {
cwd,
firstAgentContext,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
} as const;
switch (target.mode) {
case "branch-off":
return this.dependencies.createPaseoWorktreeWorkflow(
{
...baseInput,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
},
target.base ? { resolveDefaultBranch: async () => target.base! } : undefined,
);
case "checkout-branch":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
refName: target.branch,
});
case "checkout-pr":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
githubPrNumber: target.prNumber,
});
default:
throw new Error("Unsupported create_agent_request worktree target");
}
}
private registerAutoArchiveOnTerminalState(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): void {
const unsubscribe = this.dependencies.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
if (
event.event.type !== "turn_completed" &&
event.event.type !== "turn_failed" &&
event.event.type !== "turn_canceled"
) {
return;
}
unsubscribe();
void this.autoArchiveAgentOnce(agentId, options);
},
{ agentId, replayState: false },
);
}
private async autoArchiveAgentOnce(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): Promise<void> {
if (this.autoArchiveAgentIds.has(agentId)) {
return;
}
this.autoArchiveAgentIds.add(agentId);
try {
if (options.worktreePath) {
await this.archiveAutoCreatedWorktree({
agentId,
worktreePath: options.worktreePath,
repoRoot: options.repoRoot,
});
return;
}
await this.dependencies.archiveAgentForClose(agentId);
} catch (error) {
this.dependencies.logger.warn({ err: error, agentId }, "Failed to auto-archive agent");
}
}
private async archiveAutoCreatedWorktree(options: {
agentId: string | null;
worktreePath: string;
repoRoot: string | null;
}): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
paseoHome: this.dependencies.paseoHome,
});
if (!ownership.allowed) {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
}
await archivePaseoWorktree(
{
paseoHome: this.dependencies.paseoHome,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emit: this.dependencies.emit,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
isPathWithinRoot: this.dependencies.isPathWithinRoot,
killTerminalsUnderPath: this.dependencies.killTerminalsUnderPath,
sessionLogger: this.dependencies.logger,
},
{
targetPath: options.worktreePath,
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
requestId: randomUUID(),
},
);
if (options.agentId) {
this.dependencies.emitAgentRemove(options.agentId);
}
}
}
@@ -1416,7 +1416,27 @@ describe("transformPiModels", () => {
describe("ACPAgentSession slash commands", () => {
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
const session = createSession();
const session = new ACPAgentSession(
{
provider: "claude-acp",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["claude", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
waitForInitialCommands: false,
},
);
await expect(session.listCommands()).resolves.toEqual([]);
});
@@ -91,7 +91,7 @@ async function collectUntil(
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent) => boolean,
predicate: (event: AgentStreamEvent, events: AgentStreamEvent[]) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
return new Promise((resolve, reject) => {
@@ -103,7 +103,7 @@ function collectSubscribedUntil(
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!predicate(event)) {
if (!predicate(event, events)) {
return;
}
clearTimeout(timeout);
@@ -252,7 +252,7 @@ describe("ClaudeAgentSession integration", () => {
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
supportedEffortLevels: expect.arrayContaining(["low", "medium", "high", "max"]),
}),
);
expect(models).toContainEqual(
@@ -374,6 +374,14 @@ describe("ClaudeAgentSession integration", () => {
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const liveEventsPromise = collectSubscribedUntil(
handle.session,
(event, events) =>
isTerminalEvent(event) &&
compactText(getAssistantText(events)).includes(compactText(autonomousWakeToken)),
90_000,
);
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
@@ -390,15 +398,12 @@ describe("ClaudeAgentSession integration", () => {
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
90_000,
);
const liveEvents = await liveEventsPromise;
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(compactText(autonomousWakeToken));
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
@@ -14,6 +14,7 @@ interface CursorACPAgentClientOptions {
}
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
export class CursorACPAgentClient extends GenericACPAgentClient {
@@ -21,7 +22,16 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
private readonly env?: Record<string, string>;
constructor(options: CursorACPAgentClientOptions) {
super(options);
super({
logger: options.logger,
command: options.command,
env: options.env,
providerId: options.providerId,
label: options.label,
// cursor-agent publishes slash commands asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
});
this.cursorCommand = options.command;
this.env = options.env;
}
@@ -27,6 +27,8 @@ interface GenericACPAgentClientOptions {
env?: Record<string, string>;
providerId?: string;
label?: string;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
}
export class GenericACPAgentClient extends ACPAgentClient {
@@ -42,6 +44,8 @@ export class GenericACPAgentClient extends ACPAgentClient {
env: options.env,
},
defaultCommand: options.command,
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
});
this.command = options.command;
@@ -1138,6 +1138,40 @@ describe("OpenCode adapter startTurn error handling", () => {
});
});
describe("OpenCodeAgentClient env", () => {
test("passes launch-context env to env-specific server acquisition", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
try {
const session = await client.createSession(
{
provider: "opencode",
cwd,
},
{
env: {
CHUNK14_PROBE: "expected",
},
},
);
await session.close();
expect(runtime.acquisitions[0]).toMatchObject({
force: false,
env: {
CHUNK14_PROBE: "expected",
},
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("OpenCode persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const runtime = new TestOpenCodeRuntime();
@@ -961,7 +961,10 @@ interface OpenCodeAgentClientDeps {
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
@@ -1007,7 +1010,10 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.runtime.acquireServer({
force: false,
env: launchContext?.env,
});
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
@@ -10,7 +10,10 @@ export interface OpenCodeServerAcquisition {
}
export interface OpenCodeRuntime {
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
ensureServerRunning(): Promise<{ port: number; url: string }>;
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
shutdown(): Promise<void>;
@@ -21,7 +21,10 @@ export interface OpenCodeServerAcquisition {
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
}
export interface OpenCodeServerGeneration {
@@ -91,10 +94,22 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
async acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
if (options.env) {
const server = await this.startDedicatedServer(options.env);
return this.acquireServer(server);
}
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
return this.acquireServer(server);
}
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1;
let released = false;
return {
@@ -164,7 +179,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
private async startDedicatedServer(
env: Record<string, string>,
): Promise<OpenCodeServerGeneration> {
const server = await this.startServer(env);
server.retired = true;
this.retiredServers.add(server);
return server;
}
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
@@ -179,7 +203,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
},
);
@@ -2,6 +2,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./ser
export interface TestOpenCodeServerAcquisition {
force: boolean;
env?: Record<string, string>;
released: boolean;
}
@@ -15,9 +16,13 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
async acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
const acquisition: TestOpenCodeServerAcquisition = {
force: options.force,
env: options.env,
released: false,
};
this.acquisitions.push(acquisition);
@@ -8,7 +8,11 @@ interface OpenCodeResponse {
}
export class TestOpenCodeRuntime implements OpenCodeRuntime {
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
readonly acquisitions: Array<{
force: boolean;
env?: Record<string, string>;
releaseCount: number;
}> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
private readonly clients: TestOpenCodeClient[] = [];
@@ -18,8 +22,11 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
this.clients.push(client);
}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, releaseCount: 0 };
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
this.acquisitions.push(acquisition);
return {
server: this.server,
@@ -42,6 +42,22 @@ async function createSession(pi = new FakePi()): Promise<{
return { pi, session, events };
}
test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi();
const client = createClient(pi);
const session = await client.createSession(createConfig(), {
env: {
CHUNK14_PROBE: "expected",
},
});
expect(pi.recordedLaunches[0]?.env).toEqual({
CHUNK14_PROBE: "expected",
});
await session.close();
});
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
@@ -843,7 +843,7 @@ export class PiRpcAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
let runtimeSession: PiRuntimeSession;
@@ -857,6 +857,7 @@ export class PiRpcAgentClient implements AgentClient {
config.systemPrompt,
config.daemonAppendSystemPrompt,
),
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
});
} catch (error) {
@@ -1,6 +1,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import {
@@ -29,6 +30,14 @@ const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Pi process was spawned without stdio streams");
}
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
@@ -52,11 +61,13 @@ export class PiCliRuntime implements PiRuntime {
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
return spawn(command, args, {
const child = spawnProcess(command, args, {
cwd: launch.cwd,
env: launch.env ? { ...process.env, ...launch.env } : process.env,
envOverlay: launch.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
});
}
@@ -21,6 +21,7 @@ export interface PiRuntimeLaunch {
export interface PiStartSessionInput {
cwd: string;
env?: Record<string, string>;
model?: string;
thinkingOptionId?: string;
session?: string;
@@ -84,7 +85,13 @@ export function buildPiLaunch(input: {
return {
cwd: input.session.cwd,
argv,
env: input.runtimeSettings?.env,
env:
input.runtimeSettings?.env || input.session.env
? {
...input.runtimeSettings?.env,
...input.session.env,
}
: undefined,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
@@ -2,10 +2,12 @@ import type { ChildProcess } from "node:child_process";
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { PiCliRuntime } from "./pi/cli-runtime.js";
interface SpawnResult {
code: number | null;
@@ -16,16 +18,21 @@ interface SpawnResult {
}
interface ProviderLaunchCase {
provider: "claude" | "codex" | "opencode" | "generic-acp";
provider: "claude" | "codex" | "opencode" | "generic-acp" | "pi";
binaryName: string;
args: string[];
shell?: boolean;
launchMode?: "spawnProcess" | "piRuntime";
}
const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}';
const tempDirs: string[] = [];
function makeFixture(binaryName: string, expectedArgs: string[]) {
function makeFixture(
binaryName: string,
expectedArgs: string[],
launchMode: "spawnProcess" | "piRuntime" = "spawnProcess",
) {
const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `));
tempDirs.push(root);
@@ -41,14 +48,50 @@ if (process.argv.includes("--version")) {
process.exit(0);
}
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
const expected = ${JSON.stringify(expectedArgs)};
const actual = process.argv.slice(2);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error("ARGV_MISMATCH");
console.error(JSON.stringify({ expected, actual }));
process.exit(42);
}
console.log("ARGV_OK");
if (${JSON.stringify(launchMode)} === "piRuntime") {
let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).replace(/\\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line);
if (command.type === "get_state") {
process.stdout.write(
JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: {
sessionId: "pi-session-1",
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
},
}) + "\\n",
);
}
}
});
process.stdin.on("end", () => process.exit(0));
} else {
console.log("ARGV_OK");
}
`,
);
@@ -112,16 +155,29 @@ async function runProviderFixture(params: {
shell?: boolean;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs),
},
stdio: ["ignore", "pipe", "pipe"],
...(params.shell === undefined ? {} : { shell: params.shell }),
});
return collectChild(child);
}
async function runPiRuntimeFixture(params: { command: string; cwd: string }): Promise<void> {
const runtime = new PiCliRuntime({
logger: pino({ level: "silent" }),
command: [params.command],
});
const session = await runtime.startSession({ cwd: params.cwd });
try {
await expect(session.getState()).resolves.toMatchObject({
sessionId: "pi-session-1",
thinkingLevel: "medium",
});
} finally {
await session.close();
}
}
function withPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
const pathKey =
process.platform === "win32"
@@ -167,6 +223,12 @@ const providerLaunchCases: ProviderLaunchCase[] = [
binaryName: "generic-acp",
args: ["--mcp-config", JSON_ARG],
},
{
provider: "pi",
binaryName: "pi",
args: ["--mode", "rpc"],
launchMode: "piRuntime",
},
];
describe.runIf(process.platform === "win32")("Windows provider launch parity", () => {
@@ -194,9 +256,17 @@ describe.runIf(process.platform === "win32")("Windows provider launch parity", (
});
test.each(providerLaunchCases)(
"$provider launches a cmd shim from a path with spaces through spawnProcess",
async ({ binaryName, args, shell }) => {
const fixture = makeFixture(binaryName, args);
"$provider launches a cmd shim from a path with spaces through its Windows-safe launch path",
async ({ binaryName, args, shell, launchMode }) => {
const fixture = makeFixture(binaryName, args, launchMode);
if (launchMode === "piRuntime") {
await runPiRuntimeFixture({
command: fixture.shim,
cwd: fixture.root,
});
return;
}
const result = await runProviderFixture({
command: fixture.shim,
+1
View File
@@ -841,6 +841,7 @@ export async function createPaseoDaemon(
const speechService = createSpeechService({
logger,
paseoHome: config.paseoHome,
openaiConfig: config.openai,
speechConfig: config.speech,
});
+40 -1
View File
@@ -1,9 +1,48 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
import { serializeAgentStreamEvent } from "./messages.js";
import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js";
describe("serializeAgentStreamEvent", () => {
test("accepts create_agent_request env records", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req-env",
config: {
provider: "codex",
cwd: "/tmp",
},
env: {
CHUNK14_PROBE: "expected",
},
attachments: [],
});
expect(parsed).toMatchObject({
type: "create_agent_request",
env: {
CHUNK14_PROBE: "expected",
},
});
});
test("rejects non-string create_agent_request env values", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "create_agent_request",
requestId: "req-env",
config: {
provider: "codex",
cwd: "/tmp",
},
env: {
CHUNK14_PROBE: 14,
},
attachments: [],
});
expect(parsed.success).toBe(false);
});
test("preserves user_message text as-is", () => {
const event: AgentStreamEvent = {
type: "timeline",
@@ -0,0 +1,274 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
import type { CreateAgentOptions } from "./test-utils/index.js";
import type { CreateAgentWorktreeTarget } from "./messages.js";
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
function createGitRepo(): string {
const tempRoot = mkdtempSync(path.join(tmpdir(), "create-agent-worktree-"));
tempRoots.push(tempRoot);
const repoDir = path.join(tempRoot, "repo");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
}
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
await expect
.poll(
async () => {
const active = await ctx.client.fetchAgents();
return active.entries.map((entry) => entry.agent.id).includes(agentId);
},
{ timeout: 15000, interval: 100 },
)
.toBe(false);
}
async function expectAgentPresentInActiveList(agentId: string): Promise<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries.map((entry) => entry.agent.id)).toContain(agentId);
}
async function expectActiveAgentListEmpty(): Promise<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries).toEqual([]);
}
async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise<void> {
await expect
.poll(
async () => {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath);
},
{ timeout: 15000, interval: 100 },
)
.toBe(false);
}
async function expectWorktreePresentInList(repoDir: string, worktreePath: string): Promise<void> {
await expect
.poll(
async () => {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath);
},
{ timeout: 5000, interval: 100 },
)
.toBe(true);
}
async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(listed.worktrees).toEqual([]);
}
async function createAgentInBranchOffWorktree(options?: {
autoArchive?: boolean;
branchName?: string;
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
const repoDir = createGitRepo();
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree: {
mode: "branch-off",
newBranch: branchName,
base: "main",
},
...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
initialPrompt: "Say done.",
});
return { repoDir, agentId: created.id, worktreePath: created.cwd };
}
test("create_agent_request creates a worktree and auto-archives both after the first turn", async () => {
const repoDir = createGitRepo();
const worktree: CreateAgentWorktreeTarget = {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch-test",
base: "main",
};
const request: CreateAgentOptions & {
worktree: CreateAgentWorktreeTarget;
autoArchive: true;
} = {
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree,
autoArchive: true,
initialPrompt: "Say done.",
};
const created = await ctx.client.createAgent(request);
expect(created.cwd).not.toBe(repoDir);
const listedWithWorktree = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(listedWithWorktree.worktrees).toEqual([
expect.objectContaining({
worktreePath: created.cwd,
branchName: "agent-lifecycle-dispatch-test",
}),
]);
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
await expectWorktreeAbsentFromList(repoDir, created.cwd);
}, 30000);
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
autoArchive: true,
initialPrompt: "Say done.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id);
const worktrees = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(worktrees.worktrees).toEqual([]);
});
test("create_agent_request with autoArchive archives an agent whose first turn fails", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
autoArchive: true,
initialPrompt: "Emit a turn failure.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id);
});
test("create_agent_request without autoArchive keeps today's active listing behavior", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
initialPrompt: "Say done.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentPresentInActiveList(created.id);
});
test("create_agent_request with worktree but no autoArchive leaves agent and worktree active", async () => {
const created = await createAgentInBranchOffWorktree();
await ctx.client.waitForFinish(created.agentId, 10000);
await expectAgentPresentInActiveList(created.agentId);
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
});
test("archiving a created worktree still archives nested agents", async () => {
const created = await createAgentInBranchOffWorktree();
await ctx.client.waitForFinish(created.agentId, 10000);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath);
});
test("create_agent_request rejects legacy git options before creating a worktree", async () => {
const repoDir = createGitRepo();
await expect(
ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
git: {
createNewBranch: true,
newBranchName: "legacy-agent-branch",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch-test",
base: "main",
},
initialPrompt: "Say done.",
}),
).rejects.toThrow("worktree cannot be combined with git options");
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
});
test("create_agent_request fails cleanly when worktree creation cannot resolve target", async () => {
const repoDir = createGitRepo();
await expect(
ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree: {
mode: "checkout-branch",
branch: "does-not-exist",
},
initialPrompt: "Say done.",
}),
).rejects.toThrow();
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
});
+75 -2
View File
@@ -252,6 +252,7 @@ import {
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -826,6 +827,7 @@ export class Session {
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
@@ -911,6 +913,35 @@ export class Session {
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
paseoHome: this.paseoHome,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
github: this.github,
workspaceGitService: this.workspaceGitService,
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => {
if (this.agentUpdatesSubscription) {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
},
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) =>
this.terminalController.killTerminalsUnderPath(rootPath),
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager ?? null;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
@@ -2627,11 +2658,12 @@ export class Session {
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
const missingModelIds = state.missingAssets.find((asset) => asset.kind === "models")?.ids ?? [];
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
missingModelIds: missingModelIds as LocalSpeechModelId[],
};
}
@@ -3081,9 +3113,12 @@ export class Session {
clientMessageId,
outputSchema,
git,
worktree,
autoArchive,
images,
attachments,
labels,
env,
} = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
@@ -3092,6 +3127,8 @@ export class Session {
}`,
);
let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null;
let createdAgentId: string | null = null;
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
@@ -3107,8 +3144,18 @@ export class Session {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
cwd: config.cwd,
target: worktree,
firstAgentContext,
hasLegacyGitOptions: Boolean(git),
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...resolvedConfig, cwd: createdWorktree.worktree.worktreePath }
: resolvedConfig;
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
resolvedConfig,
createAgentConfig,
git,
worktreeName,
firstAgentContext,
@@ -3124,8 +3171,15 @@ export class Session {
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
env,
});
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
agentId: snapshot.id,
createdWorktree,
});
await this.sendInitialCreateAgentPrompt({
snapshot,
@@ -3159,6 +3213,10 @@ export class Session {
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
} catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup,
createdAgentId,
});
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
@@ -3332,6 +3390,7 @@ export class Session {
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.forwardAgentUpdate(snapshot);
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
@@ -7086,6 +7145,20 @@ export class Session {
}
}
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to register workspace for imported agent",
);
}
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
@@ -66,6 +66,20 @@ interface SessionTestAccess {
agentManager: {
listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
findPersistedAgent(
provider: string,
providerHandleId: string,
options?: unknown,
): Promise<PersistedAgentDescriptor | null>;
resumeAgentFromPersistence(
handle: unknown,
overrides?: unknown,
preferredId?: string,
extras?: unknown,
): Promise<unknown>;
hydrateTimelineFromProvider(agentId: string): Promise<unknown>;
getTimeline(agentId: string): readonly unknown[];
setTitle(agentId: string, title: string): Promise<unknown>;
};
workspaceRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -2660,6 +2674,105 @@ test("open_project_request registers a workspace before any agent exists", async
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
});
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const importedCwd = path.resolve("/tmp/imported-project");
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "imported",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
const managed = makeManagedAgent({
id: "imported-agent",
cwd: importedCwd,
lifecycle: "idle",
updatedAt: "2026-05-21T00:00:00.000Z",
});
session.agentManager.listAgents = () => [managed];
session.agentManager.findPersistedAgent = async () => null;
session.agentManager.resumeAgentFromPersistence = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.agentManager.setTitle = async () => undefined;
session.agentStorage.list = async () => [];
session.agentStorage.get = async () => null;
session.forwardAgentUpdate = async () => undefined;
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-import",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
importedCwd,
{
id: importedCwd,
projectId: importedCwd,
projectDisplayName: "imported-project",
projectRootPath: importedCwd,
projectKind: "non_git",
workspaceKind: "directory",
name: "imported-project",
status: "done",
activityAt: null,
},
],
]);
await session.handleMessage({
type: "import_agent_request",
requestId: "req-import",
providerId: "codex",
providerHandleId: "session-xyz",
cwd: importedCwd,
});
expect(workspaces.get(importedCwd)).toBeTruthy();
const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0);
expect(
workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
),
).toBe(true);
});
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -11,6 +11,7 @@ import {
pcm16leToFloat32,
} from "../../../audio.js";
import { Pcm16MonoResampler } from "../../../../agent/pcm16-resampler.js";
import { loadLocalSpeechPackage } from "../runtime/index.js";
type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
@@ -98,7 +99,9 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return import("onnxruntime-node");
return loadLocalSpeechPackage<OrtModule>("onnxruntime-node", {
requireFrom: import.meta.url,
});
}
interface SentencePieceModule {
@@ -0,0 +1,20 @@
import type { LocalSpeechRuntimePackageId } from "./package-specs.js";
export interface LocalSpeechRuntimeStatus {
runtimeDir: string;
missingPackageIds: LocalSpeechRuntimePackageId[];
}
export class LocalSpeechRuntimeUnavailableError extends Error {
public readonly code = "local_speech_runtime_not_installed";
public readonly runtimeDir: string;
public readonly missingPackageIds: LocalSpeechRuntimePackageId[];
constructor(status: LocalSpeechRuntimeStatus) {
const missing = status.missingPackageIds.join(", ");
super(`Local voice runtime is downloading / not installed (${missing}).`);
this.name = "LocalSpeechRuntimeUnavailableError";
this.runtimeDir = status.runtimeDir;
this.missingPackageIds = [...status.missingPackageIds];
}
}
@@ -0,0 +1,10 @@
export { LocalSpeechRuntimeUnavailableError } from "./errors.js";
export type { LocalSpeechRuntimeStatus } from "./errors.js";
export { ensureLocalSpeechRuntime } from "./install.js";
export {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
loadLocalSpeechPackage,
} from "./load-package.js";
export { type LocalSpeechRuntimePackageId } from "./package-specs.js";
export { getLocalSpeechRuntimeStatus } from "./status.js";
@@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureLocalSpeechRuntime } from "./index.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
const logger = pino({ level: "silent" });
function makePaseoHome(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-speech-runtime-"));
}
function writeRuntimePackageFiles(paseoHome: string): void {
for (const spec of getLocalSpeechRuntimePackageSpecs()) {
const packageDir = getLocalSpeechRuntimePackageDir(paseoHome, spec.packageName);
for (const relPath of spec.requiredFiles) {
const absPath = path.join(packageDir, relPath);
mkdirSync(path.dirname(absPath), { recursive: true });
writeFileSync(absPath, "x");
}
if (spec.id === "onnxruntime-node") {
const bindingPath = path.join(
packageDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
);
mkdirSync(path.dirname(bindingPath), { recursive: true });
writeFileSync(bindingPath, "x");
}
}
}
describe("local speech runtime installer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ensureLocalSpeechRuntime succeeds without downloading when packages are installed", async () => {
const paseoHome = makePaseoHome();
writeRuntimePackageFiles(paseoHome);
const fetchMock = vi.spyOn(globalThis, "fetch");
const status = await ensureLocalSpeechRuntime({
paseoHome,
logger,
});
expect(status.missingPackageIds).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type pino from "pino";
import {
downloadToFile,
extractTarArchive,
hasRequiredFiles,
isNonEmptyFile,
} from "../sherpa/model-downloader.js";
import { LocalSpeechRuntimeUnavailableError, type LocalSpeechRuntimeStatus } from "./errors.js";
import {
getLocalSpeechRuntimePackageSpecs,
type LocalSpeechRuntimePackageSpec,
} from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
import { getLocalSpeechRuntimeStatus } from "./status.js";
function npmTarballUrl(packageName: string, version: string): string {
const escapedName = packageName.startsWith("@") ? packageName.replace("/", "%2f") : packageName;
const basename = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
return `https://registry.npmjs.org/${escapedName}/-/${basename}-${version}.tgz`;
}
async function installPackageFromTarball(params: {
runtimeDir: string;
spec: LocalSpeechRuntimePackageSpec;
}): Promise<void> {
const { runtimeDir, spec } = params;
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
if (
(await hasRequiredFiles(packageDir, spec.requiredFiles)) &&
(!spec.validate || spec.validate(packageDir))
) {
return;
}
const downloadsDir = path.join(runtimeDir, ".downloads");
const archivePath = path.join(
downloadsDir,
`${spec.packageName.replace("/", "__")}-${spec.version}.tgz`,
);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: npmTarballUrl(spec.packageName, spec.version),
outputPath: archivePath,
});
}
const extractDir = path.join(
runtimeDir,
".extract",
`${spec.packageName.replace("/", "__")}-${Date.now()}`,
);
const extractedPackageDir = path.join(extractDir, "package");
const nextPackageDir = `${packageDir}.tmp-${Date.now()}`;
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(packageDir), { recursive: true });
await extractTarArchive(archivePath, extractDir);
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(extractedPackageDir, nextPackageDir);
if (
!(await hasRequiredFiles(nextPackageDir, spec.requiredFiles)) ||
(spec.validate && !spec.validate(nextPackageDir))
) {
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error(
`Downloaded ${spec.packageName}@${spec.version}, but required runtime files are missing.`,
);
}
await rm(packageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(nextPackageDir, packageDir);
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await rm(archivePath, { force: true }).catch(() => undefined);
}
export async function ensureLocalSpeechRuntime(options: {
paseoHome: string;
logger: pino.Logger;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const logger = options.logger.child({
module: "speech",
provider: "local",
component: "runtime-installer",
});
const runtimeDir = getLocalSpeechRuntimeDir(options.paseoHome);
const specs = getLocalSpeechRuntimePackageSpecs(options.platform, options.arch);
const before = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (before.missingPackageIds.length === 0) {
return before;
}
logger.info(
{
runtimeDir,
missingPackageIds: before.missingPackageIds,
},
"Starting local speech runtime install",
);
for (const spec of specs) {
await installPackageFromTarball({ runtimeDir, spec });
}
const after = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (after.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(after);
}
logger.info({ runtimeDir }, "Local speech runtime install completed");
return after;
}
@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { LocalSpeechRuntimeUnavailableError } from "./errors.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
import { getLocalSpeechRuntimeStatusSync } from "./status.js";
import {
prependEnvPath,
sherpaLoaderEnvKey,
sherpaPlatformPackageName,
} from "../sherpa/sherpa-runtime-env.js";
let configuredPaseoHome: string | null = null;
export function configureLocalSpeechRuntimeHome(paseoHome: string): void {
configuredPaseoHome = paseoHome;
}
export function clearLocalSpeechRuntimeHome(): void {
configuredPaseoHome = null;
}
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
const lower = key.toLowerCase();
for (const k of Object.keys(env)) {
if (k.toLowerCase() === lower) return k;
}
return key;
}
function applyConfiguredSherpaLoaderEnv(): void {
if (!configuredPaseoHome) {
return;
}
const envKey = sherpaLoaderEnvKey();
if (!envKey) {
return;
}
const platformPackageDir = getLocalSpeechRuntimePackageDir(
configuredPaseoHome,
sherpaPlatformPackageName(),
);
const actualKey = findEnvKey(process.env, envKey);
process.env[actualKey] = prependEnvPath(process.env[actualKey], platformPackageDir);
}
export function loadLocalSpeechPackage<T>(
packageName: string,
options?: { requireFrom?: string },
): T {
const devRequire = createRequire(options?.requireFrom ?? import.meta.url);
if (!configuredPaseoHome) {
return devRequire(packageName) as T;
}
if (packageName === "sherpa-onnx-node") {
applyConfiguredSherpaLoaderEnv();
}
const status = getLocalSpeechRuntimeStatusSync({ paseoHome: configuredPaseoHome });
if (status.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(status);
}
const runtimePackageDir = getLocalSpeechRuntimePackageDir(configuredPaseoHome, packageName);
const runtimePackageJson = path.join(runtimePackageDir, "package.json");
if (existsSync(runtimePackageJson)) {
const runtimeRequire = createRequire(runtimePackageJson);
return runtimeRequire(runtimePackageDir) as T;
}
throw new LocalSpeechRuntimeUnavailableError(status);
}
@@ -0,0 +1,85 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { sherpaPlatformPackageName } from "../sherpa/sherpa-runtime-env.js";
const SHERPA_ONNX_VERSION = "1.12.28";
const ONNXRUNTIME_VERSION = "1.24.3";
export type LocalSpeechRuntimePackageId =
| "sherpa-onnx"
| "sherpa-onnx-node"
| "sherpa-platform"
| "onnxruntime-common"
| "onnxruntime-node";
export interface LocalSpeechRuntimePackageSpec {
id: LocalSpeechRuntimePackageId;
packageName: string;
version: string;
requiredFiles: string[];
validate?: (packageDir: string) => boolean;
}
function hasOnnxruntimeNativeBinding(
packageDir: string,
platform: NodeJS.Platform,
arch: string,
): boolean {
try {
const binDir = path.join(packageDir, "bin");
const napiDirs = readdirSync(binDir).filter((entry) => entry.startsWith("napi-"));
return napiDirs.some((napiDir) => {
const archDir = path.join(binDir, napiDir, platform, arch);
try {
const files = readdirSync(archDir);
return files.some((file) => file.endsWith(".node"));
} catch {
return false;
}
});
} catch {
return false;
}
}
export function getLocalSpeechRuntimePackageSpecs(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): LocalSpeechRuntimePackageSpec[] {
const sherpaPlatformPackage = sherpaPlatformPackageName(platform, arch);
return [
{
id: "sherpa-onnx",
packageName: "sherpa-onnx",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "index.js", "sherpa-onnx-wasm-nodejs.wasm"],
},
{
id: "sherpa-onnx-node",
packageName: "sherpa-onnx-node",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.js", "addon.js"],
},
{
id: "sherpa-platform",
packageName: sherpaPlatformPackage,
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.node"],
},
{
id: "onnxruntime-common",
packageName: "onnxruntime-common",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/cjs/index.js"],
},
{
id: "onnxruntime-node",
packageName: "onnxruntime-node",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/index.js"],
// onnxruntime-node encodes the N-API version in the native binding path.
validate: (packageDir) => hasOnnxruntimeNativeBinding(packageDir, platform, arch),
},
];
}
@@ -0,0 +1,22 @@
import path from "node:path";
const NODE_MODULES_DIR = "node_modules";
export function getLocalSpeechRuntimeDir(paseoHome: string): string {
return path.join(paseoHome, "runtime", "local-speech");
}
export function getLocalSpeechRuntimeNodeModulesDir(paseoHome: string): string {
return path.join(getLocalSpeechRuntimeDir(paseoHome), NODE_MODULES_DIR);
}
export function getLocalSpeechRuntimePackageDir(paseoHome: string, packageName: string): string {
return path.join(getLocalSpeechRuntimeNodeModulesDir(paseoHome), packageName);
}
export function getLocalSpeechRuntimePackageDirFromRuntimeDir(
runtimeDir: string,
packageName: string,
): string {
return path.join(runtimeDir, NODE_MODULES_DIR, packageName);
}

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