Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 6983ca322d Add Antigravity CLI to ACP catalog 2026-05-20 10:57:29 +07:00
123 changed files with 1314 additions and 4783 deletions
-11
View File
@@ -1,11 +0,0 @@
---
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
@@ -1,11 +0,0 @@
---
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
@@ -1 +0,0 @@
../../.agents/skills/release-beta
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/release-stable
+1 -46
View File
@@ -1,50 +1,5 @@
# 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
@@ -725,7 +680,7 @@
### Added
- Pi (pi.dev) agent provider — connect Pi as a new agent type with thinking levels and tool call support
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based 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, GitHub Copilot, OpenCode, and Pi.
**Supported agents:** Claude Code, Codex, and OpenCode.
## Repository map
+2 -4
View File
@@ -19,7 +19,7 @@
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode 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, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, and OpenCode 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,9 +49,7 @@ 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,8 +27,6 @@ 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,10 +38,6 @@ 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
+10 -13
View File
@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
│ (Node.js) │
└──────┬──────┘
│
┌────────────┼────────────┬────────────┬────────────┐
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode │
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
@@ -146,8 +146,6 @@ 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:**
@@ -217,17 +215,16 @@ initializing → idle ⇄ running
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
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:
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:
| 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 | ACP wrapper (`acp-agent`) | Provider-managed |
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi | Local Pi RPC process | Provider-managed |
| Pi-direct | Direct Anthropic API call | Stateless |
| Mock load test | In-process fake | In-memory |
All providers:
+1 -1
View File
@@ -339,7 +339,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
Paseo also ships an in-app ACP provider catalog for common agents, including Google Antigravity CLI, Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
+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, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). 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
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=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`
- `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`
- `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`
+15 -17
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**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
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.
## Standard release (patch)
@@ -51,11 +51,10 @@ 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.
- **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.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
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
@@ -265,15 +264,18 @@ 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` 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.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 ownership
@@ -357,7 +359,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
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.
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -373,19 +375,15 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
## Changelog scope
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.
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.
## 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/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` |
| 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` |
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,34 +80,6 @@ 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,17 +124,6 @@ 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 {
@@ -264,8 +253,6 @@ 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-a4fXFcmH0dddKpaMPdWeHtB8oyajugBeWpWnGB/18yY=
sha256-vNhmXjWV1wf17JHVX6tpTz4CKeCE7hpyBLj8nzPVUH0=
+310 -1795
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.79",
"version": "0.1.78",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.79",
"version": "0.1.78",
"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.35.5",
"react-native-nitro-modules": "0.33.8",
"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.2.4",
"react-native-unistyles": "^3.0.15",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
@@ -105,8 +105,7 @@
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@vitest/browser": "^3.2.4",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -116,7 +115,7 @@
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^4.1.6",
"vitest": "^3.2.4",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
}
-1
View File
@@ -3,7 +3,6 @@
<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
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
+14 -4
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 { redirectMock, state } = vi.hoisted(() => {
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
@@ -23,6 +23,7 @@ const { redirectMock, state } = vi.hoisted(() => {
};
return {
navigateToWorkspaceMock: vi.fn(),
redirectMock: vi.fn(),
state: hoistedState,
};
@@ -50,6 +51,7 @@ vi.mock("@/screens/startup-splash-screen", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
@@ -70,6 +72,7 @@ describe("Index route startup navigation", () => {
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
navigateToWorkspaceMock.mockReset();
redirectMock.mockReset();
container = document.createElement("div");
@@ -105,6 +108,7 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
expect(redirectMock).not.toHaveBeenCalled();
});
@@ -114,8 +118,11 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
});
it("restores the persisted workspace even when the first online host is different", async () => {
@@ -124,7 +131,10 @@ describe("Index route startup navigation", () => {
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
});
it("navigates to the host root when no persisted workspace exists", async () => {
+11 -9
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,15 +37,17 @@ export default function Index() {
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
React.useEffect(() => {
if (!startupWorkspaceSelection) {
return;
}
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
currentPathname: pathname,
});
}, [pathname, startupWorkspaceSelection]);
if (startupWorkspaceSelection) {
return (
<Redirect
href={buildHostWorkspaceRoute(
startupWorkspaceSelection.serverId,
startupWorkspaceSelection.workspaceId,
)}
/>
);
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
if (redirectRoute) {
@@ -5,6 +5,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>\n <path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n <line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>\n</svg>\n',
"amp-acp":
'<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="currentColor"/>\n<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="currentColor"/>\n<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="currentColor"/>\n<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="currentColor"/>\n</svg>\n',
antigravity:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <path d="M8 1.5L13.75 14.5H11.45L10.2 11.45H5.75L4.55 14.5H2.25L8 1.5ZM6.5 9.55H9.45L8 5.95L6.5 9.55Z" fill="currentColor"/>\n <path d="M2.25 7.9C2.25 5.1 4.55 2.85 7.4 2.85C10.75 2.85 13.35 5.65 13.35 8.9C13.35 11.4 11.35 13.15 9.1 13.15" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>\n</svg>\n',
auggie:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">\n <path fill="currentColor" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>\n <path fill="currentColor" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>\n</svg>\n',
autohand:
@@ -7,7 +7,6 @@ import {
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
type GetDirectorySuggestions,
} from "./resolver";
const CONTEXT: AssistantFileLinkContext = {
@@ -21,7 +20,7 @@ function resolvedSuggestions(
}
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
getDirectorySuggestions: GetDirectorySuggestions;
getDirectorySuggestions: ReturnType<typeof vi.fn>;
searches: Array<{
query: string;
cwd: string;
@@ -35,7 +34,7 @@ function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEn
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
const getDirectorySuggestions = vi.fn(
async (input: {
query: string;
cwd: string;
@@ -193,7 +192,7 @@ describe("fetchDaemonResolution", () => {
});
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
const getDirectorySuggestions = vi.fn(async () => {
throw new Error("daemon unavailable");
});
@@ -5,7 +5,6 @@ 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";
@@ -64,15 +63,15 @@ function createQueryClient(): QueryClient {
});
}
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?: {
show: ReturnType<typeof vi.fn>;
copied: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
}
function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; toast?: ToastApi }) {
}) {
const queryClient = createQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
const openWorkspaceFile = useCallback(
@@ -119,7 +118,7 @@ describe("useFileLink", () => {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
onOpenWorkspaceFile: () => {},
toast: createToast(),
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
},
children,
),
@@ -146,7 +145,7 @@ describe("useFileLink", () => {
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const toast = createToast();
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
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 { Home, Plus, Settings } from "lucide-react-native";
import { 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,8 +106,6 @@ 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 }],
+18 -36
View File
@@ -5,7 +5,6 @@ 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";
@@ -96,27 +95,20 @@ export function DiffViewer({
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
() => [
styles.linesContainer,
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
],
() => [styles.linesContainer, scrollViewWidth > 0 && { 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 (
@@ -126,39 +118,29 @@ export function DiffViewer({
);
}
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 = (
return (
<ScrollView
style={outerScrollStyle}
contentContainerStyle={webVerticalContentStyle}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{horizontalScroll}
<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>
</ScrollView>
);
return content;
}
const styles = StyleSheet.create((theme) => {
+1 -5
View File
@@ -17,7 +17,6 @@ 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";
@@ -121,10 +120,7 @@ const CodeLine = React.memo(function CodeLine({
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
@@ -13,7 +13,6 @@ 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";
@@ -80,7 +79,7 @@ function useDetailStyles(
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -89,7 +88,7 @@ function useDetailStyles(
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -98,7 +97,7 @@ function useDetailStyles(
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],
+9 -12
View File
@@ -20,8 +20,6 @@ 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";
@@ -58,7 +56,6 @@ import {
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
const IS_WEB = isWeb;
@@ -861,7 +858,7 @@ interface DesktopContainerStyleInput {
availableHeight: number | undefined;
}
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
const {
desktopMinWidth,
referenceWidth,
@@ -880,6 +877,7 @@ function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<Vi
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
styles.desktopContainer,
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
@@ -1072,7 +1070,7 @@ interface DesktopBodyProps {
handleClose: () => void;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopFrameStyle: StyleProp<ViewStyle>;
desktopContainerStyle: unknown;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
@@ -1193,12 +1191,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
>
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
<FloatingSurface
<Animated.View
testID="combobox-desktop-container"
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={styles.desktopContainer}
frameStyle={props.desktopFrameStyle}
style={props.desktopContainerStyle as never}
ref={props.refs.setFloating}
collapsable={false}
onLayout={props.handleDesktopContentLayout}
@@ -1230,7 +1227,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
renderOption={props.renderOption}
/>
)}
</FloatingSurface>
</Animated.View>
</View>
</Modal>
);
@@ -1495,9 +1492,9 @@ export function Combobox({
[theme.colors.palette.zinc],
);
const desktopFrameStyle = useMemo(
const desktopContainerStyle = useMemo(
() =>
buildDesktopFrameStyle({
buildDesktopContainerStyle({
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
@@ -1564,7 +1561,7 @@ export function Combobox({
handleClose={handleClose}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopFrameStyle={desktopFrameStyle}
desktopContainerStyle={desktopContainerStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}
@@ -18,6 +18,7 @@ import {
Modal,
Platform,
Pressable,
ScrollView,
StatusBar,
Text,
View,
@@ -27,7 +28,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import Animated, { 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";
@@ -37,7 +38,6 @@ 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 frameStyle = useMemo(() => {
const animatedContentStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -508,6 +508,7 @@ export function ContextMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -565,24 +566,23 @@ export function ContextMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<FloatingSurface
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={styles.content}
frameStyle={frameStyle}
style={animatedContentStyle}
>
<FloatingScrollView
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</FloatingScrollView>
</FloatingSurface>
</ScrollView>
</Animated.View>
</View>
</Modal>
);
@@ -14,6 +14,7 @@ import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Text,
View,
Dimensions,
@@ -24,10 +25,10 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import { Keyframe, runOnJS } from "react-native-reanimated";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -171,37 +172,42 @@ function computePosition({
return { x, y, actualPlacement };
}
function renderDropdownSurface(input: {
frameStyle: StyleProp<ViewStyle>;
interface SharedDropdownContentProps {
collapsable: false;
testID?: string;
surfaceStyle: StyleProp<ViewStyle>;
style: StyleProp<ViewStyle>;
}
function renderDropdownSurface(input: {
isWebSurface: boolean;
sharedProps: SharedDropdownContentProps;
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
onExited: () => void;
}): ReactElement {
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
const body = scrollable ? (
<FloatingScrollView
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{content}
</FloatingScrollView>
</ScrollView>
) : (
content
);
if (isWebSurface) {
return <View {...sharedProps}>{body}</View>;
}
return (
<FloatingSurface
collapsable={false}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
<Animated.View
{...sharedProps}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
@@ -211,7 +217,7 @@ function renderDropdownSurface(input: {
})}
>
{body}
</FloatingSurface>
</Animated.View>
);
}
@@ -507,8 +513,7 @@ export function DropdownMenuContent({
[],
);
const surfaceStyle = styles.content;
const frameStyle = useMemo(() => {
const contentStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -518,6 +523,7 @@ export function DropdownMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -537,6 +543,14 @@ 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],
@@ -569,9 +583,8 @@ export function DropdownMenuContent({
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
isWebSurface: isWeb,
sharedProps: sharedContentProps,
scrollable,
scrollViewportStyle,
content,
@@ -1,63 +0,0 @@
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>
);
}
+10 -12
View File
@@ -25,10 +25,9 @@ import {
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import { FadeIn, FadeOut } from "react-native-reanimated";
import Animated, { 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";
@@ -495,18 +494,19 @@ export function TooltipContent({
[],
);
const frameStyle = useMemo(
const contentStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, position?.x, position?.y],
[maxWidth, style, 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}>
<FloatingSurface
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -527,10 +527,9 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</Animated.View>
</View>
</Portal>
);
@@ -545,7 +544,7 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -553,10 +552,9 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</Animated.View>
</Pressable>
</Modal>
);
@@ -1,20 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
type GestureResponderEvent,
View,
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;
@@ -30,15 +27,6 @@ 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;
@@ -71,13 +59,6 @@ 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,
@@ -134,6 +115,13 @@ export function useWebDesktopScrollbarMetrics() {
};
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
@@ -285,12 +273,8 @@ export function WebDesktopScrollbarOverlay({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (event: GestureResponderEvent) => {
const clientY = readClientY(event);
onPanResponderGrant: () => {
dragStartOffsetRef.current = normalizedOffsetRef.current;
if (clientY !== null) {
dragStartClientYRef.current = clientY;
}
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
@@ -378,19 +362,20 @@ export function WebDesktopScrollbarOverlay({
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
inlineUnistylesStyle({
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
}),
},
platformIsWeb &&
inlineUnistylesStyle({
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} satisfies WebPointerStyle as unknown as ViewStyle),
} as object),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
@@ -398,19 +383,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",
} satisfies WebPointerStyle as unknown as ViewStyle),
} as object),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
@@ -428,6 +413,8 @@ export function WebDesktopScrollbarOverlay({
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
@@ -459,6 +446,5 @@ 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 { FadeIn, FadeOut } from "react-native-reanimated";
import Animated, { 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,7 +24,6 @@ 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 {
@@ -255,19 +254,23 @@ function WorkspaceHoverCardContent({
[],
);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
const cardStyle = useMemo(
() => [
styles.card,
{
width: HOVER_CARD_WIDTH,
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}>
<FloatingSurface
<Animated.View
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -276,8 +279,7 @@ function WorkspaceHoverCardContent({
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
style={styles.card}
frameStyle={frameStyle}
style={cardStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
@@ -301,7 +303,7 @@ function WorkspaceHoverCardContent({
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</>
) : null}
</FloatingSurface>
</Animated.View>
</View>
</Portal>
);
@@ -439,7 +441,6 @@ 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,
@@ -31,6 +31,15 @@ const CATALOG_DATA = [
installLink: "https://github.com/tao12345666333/amp-acp",
command: ["amp-acp"],
},
{
id: "antigravity",
title: "Google Antigravity CLI",
description: "Terminal-first surface to interact with Antigravity agents",
version: "1.0.0",
iconId: "antigravity",
installLink: "https://www.antigravity.google/product/antigravity-cli",
command: ["paseo", "acp", "antigravity"],
},
{
id: "auggie",
title: "Auggie CLI",
+10 -7
View File
@@ -17,7 +17,6 @@ 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";
@@ -111,20 +110,18 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled, theme.colors.surface2],
[primaryDisabled],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) &&
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[theme.colors.surface2],
[],
);
return (
@@ -233,6 +230,9 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
@@ -258,6 +258,9 @@ const styles = StyleSheet.create((theme) => ({
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
iconButton: {
width: 32,
height: 32,
+44 -81
View File
@@ -78,7 +78,6 @@ 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 {
@@ -114,6 +113,25 @@ 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;
@@ -256,12 +274,7 @@ function DiffGutterCell({
style?: StyleProp<ViewStyle>;
}) {
const containerStyle = useMemo(
() => [
styles.gutterCell,
lineTypeBackground(type),
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
[type, gutterWidth, style],
);
const textStyle = useMemo(
@@ -531,10 +544,7 @@ function InlineReviewThreadContent({
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
if (height === 0) {
return null;
}
@@ -570,11 +580,7 @@ function InlineReviewGutterSpacer({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.inlineReviewGutterSpacer,
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
style,
],
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
[gutterWidth, height, style],
);
if (height === 0) {
@@ -598,13 +604,10 @@ function InlineReviewRow({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
[gutterWidth],
);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
if (height === 0) {
return null;
}
@@ -653,10 +656,7 @@ function SplitDiffColumn({
[showDivider],
);
const linesContainerRowStyle = useMemo(
() => [
styles.linesContainer,
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
],
() => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }],
[scrollWidth],
);
@@ -916,10 +916,7 @@ function DiffFileBody({
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const linesContainerRowStyle = useMemo(
() => [
styles.linesContainer,
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
],
() => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }],
[availableWidth],
);
@@ -1065,7 +1062,7 @@ interface GitDiffPaneProps {
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
state: PressableStateCallbackType & { hovered?: boolean },
) => StyleProp<ViewStyle>;
interface DiffLayoutToggleGroupProps {
@@ -1439,30 +1436,14 @@ 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 || Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
selected && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
];
}
@@ -1513,50 +1494,32 @@ 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],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.layout === "split",
[styles.toggleButton, styles.toggleButtonGroupEnd],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
[wrapLines, controlSurfaceColor],
);
const expandAllToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
);
const {
@@ -2083,7 +2046,7 @@ export function GitDiffPane({
allExpanded={allExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
expandAllToggleStyle={expandAllButtonStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
@@ -36,6 +36,7 @@ describe("ACP provider catalog", () => {
it("uses PATH commands for entries that were binary distributions upstream", () => {
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
expect(findProvider("antigravity").command).toEqual(["paseo", "acp", "antigravity"]);
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
@@ -58,6 +59,14 @@ describe("ACP provider catalog", () => {
});
});
it("bundles the Antigravity catalog metadata and icon", () => {
const provider = findProvider("antigravity");
expect(provider.description).toBe("Terminal-first surface to interact with Antigravity agents");
expect(provider.installLink).toBe("https://www.antigravity.google/product/antigravity-cli");
expect(provider.iconSvg).toContain("<svg");
});
it("preserves provider env in the daemon config patch", () => {
const patch = buildAcpProviderConfigPatch(findProvider("auggie"));
+15 -33
View File
@@ -10,7 +10,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { 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" | "home";
icon?: "plus" | "settings";
actionId?: string;
keywords: string[];
routeKind: "settings" | "home" | "none";
routeKind: "settings" | "none";
}
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -67,13 +67,6 @@ 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",
@@ -96,7 +89,7 @@ export interface CommandCenterActionItem {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings" | "home";
icon?: "plus" | "settings";
route?: Href;
shortcutKeys?: ShortcutKey[][];
}
@@ -162,32 +155,21 @@ 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) => {
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]);
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]);
const items = useMemo(() => {
if (!open) {
@@ -2,7 +2,6 @@
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";
@@ -10,19 +9,7 @@ import { useLoadOlderAgentHistory } from "./use-load-older-agent-history";
const serverId = "server-1";
const agentId = "agent-1";
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),
) {
function makeClient(fetchAgentTimeline = vi.fn().mockResolvedValue(undefined)) {
return { fetchAgentTimeline };
}
@@ -30,7 +17,7 @@ function initialize(input?: {
cursor?: { epoch: string; startSeq: number; endSeq: number };
hasOlder?: boolean;
inFlight?: boolean;
fetchAgentTimeline?: FetchAgentTimeline;
fetchAgentTimeline?: ReturnType<typeof vi.fn>;
}) {
const client = makeClient(input?.fetchAgentTimeline);
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -82,7 +69,7 @@ describe("useLoadOlderAgentHistory", () => {
});
it("dedupes concurrent older-page requests", () => {
const fetchAgentTimeline = vi.fn<FetchAgentTimeline>(
const fetchAgentTimeline = vi.fn(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 20);
@@ -133,9 +120,9 @@ describe("useLoadOlderAgentHistory", () => {
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: true,
fetchAgentTimeline: vi.fn<FetchAgentTimeline>().mockRejectedValue(error),
fetchAgentTimeline: vi.fn().mockRejectedValue(error),
});
const toast = createToast();
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast }));
act(() => {
@@ -272,12 +272,6 @@ 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 },
@@ -396,11 +390,6 @@ 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,17 +1041,9 @@ 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;
return codeMatch;
const keyMatch = combo.key !== undefined && event.key.toLowerCase() === combo.key.toLowerCase();
return codeMatch || keyMatch;
}
function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {
+9 -29
View File
@@ -3,8 +3,6 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
export interface KeyCombo {
code: string;
key?: string;
shiftedKey?: string;
codeFallback?: boolean;
meta?: true;
ctrl?: true;
alt?: true;
@@ -16,8 +14,6 @@ export interface KeyCombo {
interface KeyMapping {
code: string;
key?: string;
shiftedKey?: string;
codeFallback?: boolean;
}
const KEY_MAP: Record<string, KeyMapping> = {};
@@ -30,28 +26,18 @@ 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: "\\", 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: "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: "Slash", key: "?" };
KEY_MAP["Space"] = { code: "Space", key: " ", codeFallback: true };
KEY_MAP["Enter"] = { code: "Enter", key: "Enter", codeFallback: true };
KEY_MAP["Space"] = { code: "Space", key: " " };
KEY_MAP["Enter"] = { code: "Enter", key: "Enter" };
KEY_MAP["Backspace"] = { code: "Backspace" };
KEY_MAP["Escape"] = { code: "Escape" };
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
@@ -124,12 +110,6 @@ 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;
+1 -2
View File
@@ -3,7 +3,6 @@ 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 {
@@ -276,7 +275,7 @@ describe("git diff inline review helpers", () => {
viewportWidth: 320,
pinToViewport: true,
}),
).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
});
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {
+2 -4
View File
@@ -15,7 +15,6 @@ 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";
@@ -392,7 +391,7 @@ export function InlineReviewThread({
() => [
styles.threadContainer,
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
inlineUnistylesStyle({ minHeight: height }),
{ minHeight: height },
],
[viewportWidth, pinToViewport, height],
);
@@ -479,8 +478,7 @@ export function getInlineReviewThreadViewportStyle({
viewportWidth?: number;
pinToViewport: boolean;
}): StyleProp<ViewStyle> {
const widthStyle =
viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null;
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
if (!pinToViewport || !isWeb) {
return widthStyle;
}
@@ -76,10 +76,6 @@ class FakeDaemonClient {
return { rttMs: 0 };
}
async checkLiveness(): Promise<{ rttMs: number }> {
return this.ping();
}
setReconnectEnabled(_enabled: boolean): void {}
setConnectionState(next: ConnectionState): void {
@@ -624,85 +620,6 @@ 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[] = [];
+2 -13
View File
@@ -142,7 +142,6 @@ 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;
@@ -435,15 +434,11 @@ 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;
@@ -707,7 +702,6 @@ 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);
@@ -716,12 +710,7 @@ export class HostRuntimeController {
}
const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now;
const isActiveOnline = isOnline && connection.id === activeConnectionId;
const interval = probeIntervalForConnection(
firstSeen,
isActiveOnline,
hasActiveOnlineConnection,
now,
);
const interval = probeIntervalForConnection(firstSeen, isActiveOnline, now);
return now - lastProbed >= interval;
});
@@ -910,7 +899,7 @@ export class HostRuntimeController {
const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await connectedClient.checkLiveness({ timeoutMs: 5000 });
const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
}
+46 -143
View File
@@ -1,12 +1,14 @@
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 { 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 { 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,
@@ -16,21 +18,14 @@ 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();
@@ -47,62 +42,40 @@ 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={52} />
<PaseoLogo size={56} />
</View>
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title="Add a project"
description="Open a folder on your machine"
<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}
onPress={handleOpenPicker}
testID="open-project-submit"
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"
/>
>
Add a project
</Button>
{isLocalDaemon ? (
<HomeTile
icon={Smartphone}
title="Pair device"
description="Connect your phone to this daemon"
<Button
variant="outline"
leftIcon={Smartphone}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
/>
>
Pair device
</Button>
) : null}
</View>
</View>
@@ -111,61 +84,10 @@ 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,
@@ -175,11 +97,10 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
justifyContent: { xs: "flex-start", md: "center" },
justifyContent: "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],
@@ -188,43 +109,25 @@ const styles = StyleSheet.create((theme) => ({
logo: {
marginBottom: theme.spacing[8],
},
tiles: {
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
width: "100%",
maxWidth: 452,
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: theme.spacing[3],
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],
},
tileHovered: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.borderAccent,
},
tilePressed: {
opacity: 0.85,
},
tileText: {
gap: theme.spacing[1],
},
tileTitle: {
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
tileDescription: {
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
@@ -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 { ImportSessionSheet } from "@/components/import-session-sheet";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
@@ -61,21 +61,9 @@ 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": "import-session-loading-spinner" }),
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
@@ -112,19 +100,18 @@ vi.mock("@/components/ui/segmented-control", () => ({
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
header,
title,
children,
testID,
}: {
visible: boolean;
header?: { title: string; actions?: ReactNode };
title: string;
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{header?.title}</h1>
{header?.actions}
<h1>{title}</h1>
{children}
</section>
) : null,
@@ -159,8 +146,6 @@ 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;
@@ -183,18 +168,15 @@ function renderSheet(
},
});
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
return render(
<QueryClientProvider client={queryClient}>
<ImportSessionSheet
<WorkspaceImportSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
cwd={cwd}
workspaceDirectory="/repo/paseo"
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
/>
</QueryClientProvider>,
);
@@ -272,7 +254,7 @@ function createSnapshotEntry(
};
}
describe("ImportSessionSheet", () => {
describe("WorkspaceImportSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -438,11 +420,11 @@ describe("ImportSessionSheet", () => {
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<ImportSessionSheet
<WorkspaceImportSheet
visible={visible}
client={client}
serverId="server-1"
cwd="/repo/paseo"
workspaceDirectory="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
@@ -490,7 +472,7 @@ describe("ImportSessionSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
@@ -526,7 +508,7 @@ describe("ImportSessionSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
@@ -672,12 +654,12 @@ describe("ImportSessionSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("import-session-filter-all"));
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
@@ -709,8 +691,8 @@ describe("ImportSessionSheet", () => {
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("import-session-filters")).toBeNull();
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
@@ -738,119 +720,4 @@ describe("ImportSessionSheet", () => {
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,7 +4,6 @@ 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";
@@ -14,6 +13,7 @@ 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,16 +24,13 @@ type RecentProviderSessionsClient = Pick<
"fetchRecentProviderSessions" | "importAgent"
>;
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
interface ImportSessionSheetProps {
interface WorkspaceImportSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
cwd?: string | null;
workspaceDirectory: string | null;
onClose: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void;
onImportedAgent: (agentId: string) => void;
}
type RecentSessionsResponse = Awaited<
@@ -86,20 +83,20 @@ function buildSessionsQueriesConfig(args: {
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
workspaceDirectory: string | null;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
const enabled = visible && Boolean(client && workspaceDirectory);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client) {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
...(cwd ? { cwd } : {}),
cwd: workspaceDirectory,
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
@@ -175,6 +172,8 @@ interface SheetStatusMessagesProps {
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
@@ -185,10 +184,12 @@ function SheetStatusMessages({
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
@@ -215,117 +216,45 @@ 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: "import-session-filter-all" },
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `import-session-filter-${provider}`,
testID: `workspace-import-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function ImportSessionSheetRow({
function WorkspaceImportSheetRow({
entry,
disabled,
importing,
showCwd,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -356,7 +285,7 @@ function ImportSessionSheetRow({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -371,25 +300,19 @@ function ImportSessionSheetRow({
<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 ImportSessionSheet({
export function WorkspaceImportSheet({
visible,
client,
serverId,
cwd,
workspaceDirectory,
onClose,
onImportedAgent,
onImported,
}: ImportSessionSheetProps) {
}: WorkspaceImportSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
@@ -407,8 +330,8 @@ export function ImportSessionSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
);
const queriesConfig = useMemo(
@@ -418,9 +341,9 @@ export function ImportSessionSheet({
sessionsQueryRoot,
visible,
client,
cwd,
workspaceDirectory,
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
);
const queries = useQueries({ queries: queriesConfig });
@@ -456,25 +379,20 @@ export function ImportSessionSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) {
if (!client || !workspaceDirectory) {
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: effectiveCwd,
cwd: workspaceDirectory,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent?.(agent.id);
onImported?.(agent);
onImportedAgent(agent.id);
},
});
@@ -495,20 +413,6 @@ export function ImportSessionSheet({
[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;
@@ -519,25 +423,21 @@ export function ImportSessionSheet({
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const { showEmptyState, emptyStateTitle } = computeEmptyState({
isLoadingSessions,
allQueriesErrored,
isQueryingProviders,
allQueriesSettled,
selectedProvider,
aggregatedCount: aggregatedEntries.length,
visibleCount: visibleEntries.length,
totalAlreadyImportedCount,
providerLabelById,
});
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const showFilter = filterProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
testID="import-session-sheet"
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
@@ -548,7 +448,7 @@ export function ImportSessionSheet({
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="import-session-filters"
testID="workspace-import-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
@@ -557,29 +457,29 @@ export function ImportSessionSheet({
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isClientReady={Boolean(client && workspaceDirectory)}
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) => (
<ImportSessionSheetRow
<WorkspaceImportSheetRow
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>
);
}
@@ -639,10 +539,6 @@ 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",
@@ -653,34 +549,4 @@ 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",
},
}));
@@ -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 { ImportSessionSheet } from "@/components/import-session-sheet";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-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>
<ImportSessionSheet
<WorkspaceImportSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceDirectory={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
@@ -1,23 +0,0 @@
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;
}
+3 -5
View File
@@ -135,8 +135,6 @@ 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;
@@ -171,9 +169,9 @@ describe("SubagentsSection", () => {
function render(
rows: SubagentRow[],
onOpenSubagent: SubagentAction = vi.fn<SubagentAction>(),
onArchiveSubagent: SubagentAction = vi.fn<SubagentAction>(),
): SubagentAction {
onOpenSubagent: ReturnType<typeof vi.fn> = vi.fn(),
onArchiveSubagent: ReturnType<typeof vi.fn> = vi.fn(),
): ReturnType<typeof vi.fn> {
act(() => {
root?.render(
<SubagentsSection
@@ -17,9 +17,6 @@ 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 {
+6 -3
View File
@@ -1,5 +1,4 @@
import { defineConfig, configDefaults } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
@@ -34,7 +33,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" }],
@@ -50,7 +49,11 @@ export default defineConfig({
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
*/
pool: "forks",
maxWorkers: 2,
poolOptions: {
forks: {
maxForks: 2,
},
},
server: {
deps: {
fallbackCJS: true,
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.79",
"version": "0.1.78",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -23,8 +23,9 @@
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79",
"@getpaseo/server": "0.1.78",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37,7 +38,7 @@
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"vitest": "^3.2.4",
"zx": "^8.8.5"
}
}
+2
View File
@@ -1,5 +1,6 @@
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createAcpCommand } from "./commands/acp/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
@@ -94,6 +95,7 @@ export function createCli(): Command {
);
// Top-level local daemon shortcuts
program.addCommand(createAcpCommand());
program.addCommand(onboardCommand());
program.addCommand(daemonStartCommand());
@@ -0,0 +1,239 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Readable, Writable } from "node:stream";
import {
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Agent,
type ContentBlock,
type InitializeRequest,
type InitializeResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
} from "@agentclientprotocol/sdk";
interface AntigravitySession {
cwd: string;
hasPrompted: boolean;
}
const DEFAULT_MODEL_ID = "default";
const DEFAULT_MODE_ID = "default";
function promptToText(blocks: ContentBlock[]): string {
return blocks
.map((block) => {
if (block.type === "text") {
return block.text;
}
if (block.type === "resource_link") {
return block.uri;
}
return "";
})
.filter((text) => text.length > 0)
.join("\n\n");
}
function runAgyPrompt(input: {
prompt: string;
cwd: string;
continueConversation: boolean;
signal: AbortSignal;
}): Promise<string> {
const args = [
...(input.continueConversation ? ["--continue"] : []),
"--print",
input.prompt,
"--print-timeout",
"5m",
];
return new Promise((resolve, reject) => {
const child = spawn("agy", args, {
cwd: input.cwd,
env: {
...process.env,
AGY_CLI_DISABLE_AUTO_UPDATE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const abort = () => {
child.kill("SIGTERM");
};
input.signal.addEventListener("abort", abort, { once: true });
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
input.signal.removeEventListener("abort", abort);
reject(error);
});
child.on("close", (code, signal) => {
input.signal.removeEventListener("abort", abort);
if (input.signal.aborted) {
resolve("");
return;
}
if (code === 0) {
resolve(stdout.trim());
return;
}
const details = stderr.trim() || stdout.trim() || `agy exited with ${signal ?? code}`;
reject(new Error(details));
});
});
}
class AntigravityACPAgent implements Agent {
private readonly sessions = new Map<string, AntigravitySession>();
private readonly pendingPrompts = new Map<string, AbortController>();
constructor(private readonly connection: AgentSideConnection) {}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: {
name: "Paseo Antigravity ACP Adapter",
version: "1.0.0",
},
agentCapabilities: {
loadSession: true,
sessionCapabilities: {
resume: {},
},
},
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const sessionId = randomUUID();
this.sessions.set(sessionId, {
cwd: params.cwd,
hasPrompted: false,
});
return this.buildSessionResponse(sessionId);
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.sessions.set(params.sessionId, {
cwd: params.cwd,
hasPrompted: true,
});
return this.buildSessionState();
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
this.sessions.set(params.sessionId, {
cwd: params.cwd,
hasPrompted: true,
});
return this.buildSessionState();
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Unknown Antigravity session: ${params.sessionId}`);
}
const prompt = promptToText(params.prompt);
const abortController = new AbortController();
this.pendingPrompts.set(params.sessionId, abortController);
try {
const text = await runAgyPrompt({
prompt,
cwd: session.cwd,
continueConversation: session.hasPrompted,
signal: abortController.signal,
});
session.hasPrompted = true;
if (abortController.signal.aborted) {
return { stopReason: "cancelled" };
}
if (text.length > 0) {
await this.connection.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text },
messageId: randomUUID(),
},
});
}
return { stopReason: "end_turn" };
} finally {
this.pendingPrompts.delete(params.sessionId);
}
}
async authenticate(): Promise<Record<string, never>> {
return {};
}
async cancel(params: { sessionId: string }): Promise<void> {
this.pendingPrompts.get(params.sessionId)?.abort();
}
private buildSessionResponse(sessionId: string): NewSessionResponse {
return {
sessionId,
...this.buildSessionState(),
};
}
private buildSessionState(): Omit<NewSessionResponse, "sessionId"> {
return {
models: {
currentModelId: DEFAULT_MODEL_ID,
availableModels: [
{
modelId: DEFAULT_MODEL_ID,
name: "Configured Antigravity model",
description: "Uses the model currently selected in Antigravity CLI.",
},
],
},
modes: {
currentModeId: DEFAULT_MODE_ID,
availableModes: [
{
id: DEFAULT_MODE_ID,
name: "Default",
description: "Use Antigravity CLI's configured permissions and settings.",
},
],
},
};
}
}
export function runAntigravityAcpAdapter(): void {
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin);
const stream = ndJsonStream(input, output);
const connection = new AgentSideConnection(
(agentConnection) => new AntigravityACPAgent(agentConnection),
stream,
);
void connection;
}
+15
View File
@@ -0,0 +1,15 @@
import { Command } from "commander";
import { runAntigravityAcpAdapter } from "./antigravity.js";
export function createAcpCommand(): Command {
const command = new Command("acp").description("Run local ACP adapter commands");
command
.command("antigravity")
.description("Run an ACP adapter for the installed agy CLI")
.action(() => {
runAntigravityAcpAdapter();
});
return command;
}
+5 -42
View File
@@ -46,12 +46,6 @@ 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)",
@@ -101,7 +95,6 @@ export interface AgentRunOptions extends CommandOptions {
base?: string;
image?: string[];
cwd?: string;
env?: string[];
label?: string[];
waitTimeout?: string;
outputSchema?: string;
@@ -327,41 +320,15 @@ 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 (!flags) return labels;
for (const labelStr of flags) {
if (!labelFlags) return labels;
for (const labelStr of labelFlags) {
const eqIndex = labelStr.indexOf("=");
if (eqIndex === -1) {
throw {
code: options.code,
message: `Invalid ${options.noun} format: ${labelStr}`,
details: `${options.pluralNoun} must be in key=value format`,
code: "INVALID_LABEL",
message: `Invalid label format: ${labelStr}`,
details: "Labels must be in key=value format",
} satisfies CommandError;
}
const key = labelStr.slice(0, eqIndex);
@@ -427,8 +394,6 @@ 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;
@@ -445,7 +410,6 @@ export async function runRunCommand(
initialPrompt: structuredPrompt,
outputSchema,
images,
env: requestEnv,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
@@ -515,7 +479,6 @@ 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.79",
"version": "0.1.78",
"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": "^4.1.6",
"vitest": "^3.2.4",
"wait-on": "8.0.5"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79",
"version": "0.1.78",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.79",
"version": "0.1.78",
"files": [
"dist"
],
@@ -43,6 +43,6 @@
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^4.1.6"
"vitest": "^3.2.4"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.79",
"version": "0.1.78",
"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": "^4.1.6"
"vitest": "^3.2.4"
}
}
+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, GitHub Copilot, OpenCode, Pi). 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, OpenCode). 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, Copilot, OpenCode, Pi adapters
├── providers/ # Claude, Codex, OpenCode adapters
├── relay-transport.ts # Outbound relay connection
└── client/daemon-client.ts # Client library for daemon connection
```
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.79",
"version": "0.1.78",
"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 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 --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: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 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
"test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1",
"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: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.79",
"@getpaseo/relay": "0.1.79",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -103,11 +103,11 @@
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^3.2.4"
},
"overrides": {
"lightningcss": "1.30.1"
+14 -1
View File
@@ -8,8 +8,21 @@ 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 = [...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)];
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 supervisor = spawn(process.execPath, supervisorArgs, {
stdio: "inherit",
@@ -58,22 +58,8 @@ function resolveDevWorkerEntry(): string {
return candidate;
}
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 resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): string | null {
@@ -91,7 +77,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, config.devMode);
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
@@ -281,84 +281,6 @@ 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();
@@ -686,62 +608,6 @@ 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,7 +249,6 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
env?: CreateAgentRequestMessage["env"];
workspaceId?: string;
initialPrompt?: string;
clientMessageId?: string;
@@ -257,8 +256,6 @@ 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>;
@@ -721,8 +718,6 @@ 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;
@@ -829,14 +824,6 @@ 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> = [];
@@ -880,8 +867,6 @@ 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;
@@ -1143,7 +1128,6 @@ 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) {
@@ -1579,54 +1563,6 @@ 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)
// ============================================================================
@@ -1901,7 +1837,6 @@ 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 } : {}),
@@ -1911,8 +1846,6 @@ 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 }
@@ -4328,10 +4261,7 @@ export class DaemonClient {
return;
}
this.consecutiveLivenessFailures = 0;
if (parsed.data.type === "pong") {
this.resolveLivenessProbe();
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
return;
}
@@ -4480,7 +4410,6 @@ 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;
@@ -4529,50 +4458,6 @@ 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);
@@ -4782,7 +4667,6 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
config,
provider,
cwd,
env: _env,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
images: _images,
@@ -1,5 +1,4 @@
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";
@@ -148,46 +147,6 @@ 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;
@@ -414,40 +373,6 @@ 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,7 +805,6 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
},
): Promise<ManagedAgent> {
@@ -827,7 +826,7 @@ export class AgentManager {
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(injectedConfig),
);
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
});
@@ -3470,11 +3469,10 @@ export class AgentManager {
: next;
}
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
env: {
...env,
PASEO_AGENT_ID: agentId,
},
};
@@ -1,227 +0,0 @@
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,27 +1416,7 @@ describe("transformPiModels", () => {
describe("ACPAgentSession slash commands", () => {
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
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,
},
);
const session = createSession();
await expect(session.listCommands()).resolves.toEqual([]);
});
@@ -91,7 +91,7 @@ async function collectUntil(
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent, events: AgentStreamEvent[]) => boolean,
predicate: (event: 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, events)) {
if (!predicate(event)) {
return;
}
clearTimeout(timeout);
@@ -252,7 +252,7 @@ describe("ClaudeAgentSession integration", () => {
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: expect.arrayContaining(["low", "medium", "high", "max"]),
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
@@ -374,14 +374,6 @@ 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,
@@ -398,12 +390,15 @@ describe("ClaudeAgentSession integration", () => {
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await liveEventsPromise;
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
90_000,
);
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,7 +14,6 @@ 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 {
@@ -22,16 +21,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
private readonly env?: Record<string, string>;
constructor(options: CursorACPAgentClientOptions) {
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,
});
super(options);
this.cursorCommand = options.command;
this.env = options.env;
}
@@ -27,8 +27,6 @@ interface GenericACPAgentClientOptions {
env?: Record<string, string>;
providerId?: string;
label?: string;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
}
export class GenericACPAgentClient extends ACPAgentClient {
@@ -44,8 +42,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
env: options.env,
},
defaultCommand: options.command,
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
});
this.command = options.command;
@@ -1138,40 +1138,6 @@ 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,10 +961,7 @@ interface OpenCodeAgentClientDeps {
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
@@ -1010,10 +1007,7 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({
force: false,
env: launchContext?.env,
});
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
@@ -10,10 +10,7 @@ export interface OpenCodeServerAcquisition {
}
export interface OpenCodeRuntime {
acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
ensureServerRunning(): Promise<{ port: number; url: string }>;
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
shutdown(): Promise<void>;
@@ -21,10 +21,7 @@ export interface OpenCodeServerAcquisition {
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
}
export interface OpenCodeServerGeneration {
@@ -94,22 +91,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
return acquisition.server;
}
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);
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
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 {
@@ -179,16 +164,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
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> {
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
@@ -203,10 +179,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
},
);
@@ -2,7 +2,6 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./ser
export interface TestOpenCodeServerAcquisition {
force: boolean;
env?: Record<string, string>;
released: boolean;
}
@@ -16,13 +15,9 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.server;
}
async acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition: TestOpenCodeServerAcquisition = {
force: options.force,
env: options.env,
released: false,
};
this.acquisitions.push(acquisition);
@@ -8,11 +8,7 @@ interface OpenCodeResponse {
}
export class TestOpenCodeRuntime implements OpenCodeRuntime {
readonly acquisitions: Array<{
force: boolean;
env?: Record<string, string>;
releaseCount: number;
}> = [];
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
private readonly clients: TestOpenCodeClient[] = [];
@@ -22,11 +18,8 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
this.clients.push(client);
}
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, releaseCount: 0 };
this.acquisitions.push(acquisition);
return {
server: this.server,
@@ -42,22 +42,6 @@ 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,7 +857,6 @@ export class PiRpcAgentClient implements AgentClient {
config.systemPrompt,
config.daemonAppendSystemPrompt,
),
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
});
} catch (error) {
@@ -1,7 +1,6 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn, 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 {
@@ -30,14 +29,6 @@ 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;
@@ -61,13 +52,11 @@ export class PiCliRuntime implements PiRuntime {
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
const child = spawnProcess(command, args, {
return spawn(command, args, {
cwd: launch.cwd,
envOverlay: launch.env,
env: launch.env ? { ...process.env, ...launch.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
});
}
@@ -21,7 +21,6 @@ export interface PiRuntimeLaunch {
export interface PiStartSessionInput {
cwd: string;
env?: Record<string, string>;
model?: string;
thinkingOptionId?: string;
session?: string;
@@ -85,13 +84,7 @@ export function buildPiLaunch(input: {
return {
cwd: input.session.cwd,
argv,
env:
input.runtimeSettings?.env || input.session.env
? {
...input.runtimeSettings?.env,
...input.session.env,
}
: undefined,
env: input.runtimeSettings?.env,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
@@ -2,12 +2,10 @@ 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;
@@ -18,21 +16,16 @@ interface SpawnResult {
}
interface ProviderLaunchCase {
provider: "claude" | "codex" | "opencode" | "generic-acp" | "pi";
provider: "claude" | "codex" | "opencode" | "generic-acp";
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[],
launchMode: "spawnProcess" | "piRuntime" = "spawnProcess",
) {
function makeFixture(binaryName: string, expectedArgs: string[]) {
const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `));
tempDirs.push(root);
@@ -48,50 +41,14 @@ if (process.argv.includes("--version")) {
process.exit(0);
}
const expected = ${JSON.stringify(expectedArgs)};
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
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);
}
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");
}
console.log("ARGV_OK");
`,
);
@@ -155,29 +112,16 @@ 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"
@@ -223,12 +167,6 @@ 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", () => {
@@ -256,17 +194,9 @@ describe.runIf(process.platform === "win32")("Windows provider launch parity", (
});
test.each(providerLaunchCases)(
"$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;
}
"$provider launches a cmd shim from a path with spaces through spawnProcess",
async ({ binaryName, args, shell }) => {
const fixture = makeFixture(binaryName, args);
const result = await runProviderFixture({
command: fixture.shim,
+1 -40
View File
@@ -1,48 +1,9 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js";
import { 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",
@@ -1,274 +0,0 @@
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);
});
+1 -73
View File
@@ -252,7 +252,6 @@ 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__";
@@ -827,7 +826,6 @@ 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;
@@ -913,35 +911,6 @@ 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;
@@ -3112,12 +3081,9 @@ export class Session {
clientMessageId,
outputSchema,
git,
worktree,
autoArchive,
images,
attachments,
labels,
env,
} = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
@@ -3126,8 +3092,6 @@ export class Session {
}`,
);
let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null;
let createdAgentId: string | null = null;
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
@@ -3143,18 +3107,8 @@ 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(
createAgentConfig,
resolvedConfig,
git,
worktreeName,
firstAgentContext,
@@ -3170,15 +3124,8 @@ 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,
@@ -3212,10 +3159,6 @@ 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) {
@@ -3389,7 +3332,6 @@ 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({
@@ -7144,20 +7086,6 @@ 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,20 +66,6 @@ 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[]>;
@@ -2674,105 +2660,6 @@ 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();
@@ -703,17 +703,6 @@ class FakeAgentSession implements AgentSession {
await this.appendHistoryEvent(turnStarted);
this.notifySubscribers(turnStarted);
if (textPrompt.toLowerCase().includes("emit a turn failure")) {
const failed: AgentStreamEvent = {
type: "turn_failed",
provider: this.providerName,
error: "Requested fake provider failure",
};
await this.appendHistoryEvent(failed);
this.notifySubscribers(failed);
return;
}
const stress = parseAgentStreamStressPrompt(textPrompt);
if (stress !== null) {
await this.emitStressTurn(stress);
@@ -1,59 +0,0 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("create_agent_request worktree and autoArchive fields", () => {
test("accepts optional worktree branch-off target and autoArchive", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
labels: {},
});
});
test("keeps legacy create_agent_request defaults unchanged", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
labels: {},
});
});
});
-21
View File
@@ -1058,28 +1058,9 @@ const GitSetupOptionsSchema = z.object({
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentWorktreeTargetSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("branch-off"),
newBranch: z.string().min(1),
base: z.string().min(1).optional(),
}),
z.object({
mode: z.literal("checkout-branch"),
branch: z.string().min(1),
}),
z.object({
mode: z.literal("checkout-pr"),
prNumber: z.number().int().positive(),
}),
]);
export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTargetSchema>;
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema,
env: z.record(z.string()).optional(),
workspaceId: z.string().optional(),
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
@@ -1088,8 +1069,6 @@ export const CreateAgentRequestMessageSchema = z.object({
images: z.array(ImageAttachmentSchema).optional(),
attachments: AgentAttachmentsSchema,
git: GitSetupOptionsSchema.optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
labels: z.record(z.string()).default({}),
requestId: z.string(),
});

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