Compare commits

..

52 Commits

Author SHA1 Message Date
Mohamed Boudra
211f5b4141 chore(release): cut 0.1.91 2026-06-08 23:59:19 +07:00
Mohamed Boudra
2293e0965a Add changelog for 0.1.91 2026-06-08 23:43:38 +07:00
Mohamed Boudra
7c68985cc0 Update ACP provider catalog pins 2026-06-08 23:41:50 +07:00
Mohamed Boudra
899849cdd5 Keep daemon worker alive on heartbeat stalls 2026-06-08 23:32:16 +07:00
Mohamed Boudra
409d67ef65 Fix app E2E GitHub harness 2026-06-08 23:03:56 +07:00
Mohamed Boudra
b0954d1616 Log daemon supervisor exit reasons 2026-06-08 23:03:56 +07:00
Mohamed Boudra
9d9f6905c6 Hide OpenCode compact summaries from chat 2026-06-08 23:03:55 +07:00
Mohamed Boudra
b04ff0e6b8 Restore global agent listing (#1420) 2026-06-08 23:53:54 +08:00
qer
a6d0046c97 fix(catalog): point Kimi entry at Kimi Code CLI (#1403)
The ACP catalog kimi entry still referenced the legacy Python kimi-cli. Moonshot's actively maintained agent is now the TypeScript Kimi Code CLI (@moonshot-ai/kimi-code), which exposes the same kimi acp server. Update title, description, version, and install link to match; the launch command is unchanged.
2026-06-08 10:19:31 +00:00
Mohamed Boudra
a1fbc91163 Add OMP as a built-in importable provider (#1388)
* Support imports from Pi-compatible providers

* Add OMP as a built-in provider

* Address Pi session scanner review

* Restore Pi session import discovery

* Update provider test for disabled OMP
2026-06-08 08:17:57 +00:00
Mohamed Boudra
7d748436f4 Add Sponsor page to website 2026-06-08 13:44:24 +07:00
Mohamed Boudra
afbb2e8bb8 Fix release-blocking CI failures 2026-06-08 12:12:40 +07:00
Mohamed Boudra
6524677960 Document checkout-local dev home defaults 2026-06-08 10:34:54 +07:00
Mohamed Boudra
09a1fe46fe Hide OpenCode compaction summaries 2026-06-08 10:14:54 +07:00
Mohamed Boudra
06a8f952db Keep Paseo MCP config out of agent storage
Runtime-injected MCP endpoints can change with daemon listen settings, so compute them for launches instead of persisting them.
2026-06-07 23:31:03 +07:00
Mohamed Boudra
893e3376b0 Recover stale supervised daemons
Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.

Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.

Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
2026-06-07 23:18:48 +07:00
Mohamed Boudra
7c8b290e2f Stabilize local dev daemon homes
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home.

Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
2026-06-07 23:18:32 +07:00
Mohamed Boudra
d55e1622cb Persist create-agent mode preferences reliably 2026-06-07 15:53:17 +07:00
Mohamed Boudra
69715a77e9 Disable pull and push until the branch diverges 2026-06-07 14:21:32 +07:00
Mohamed Boudra
e1b27fc584 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-07 14:07:45 +07:00
Mohamed Boudra
9c16bc474b Add command center to mobile
Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
2026-06-07 14:07:14 +07:00
chyendongnhanh338
59d48d235a Fix Pi compaction slash commands (#1338)
* fix: handle Pi compact slash command

* fix: handle Pi compaction slash commands

* fix: harden Pi compaction commands

* fix: handle missing Pi autocompaction state

---------

Co-authored-by: X <X@16x.org>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-06 22:41:08 +08:00
Mohamed Boudra
6aa73baaab Show useful local speech crash details (#1379)
* Improve local speech worker crash diagnostics

* Fix speech worker stderr tail diagnostics

* Drain speech worker stderr before reporting exit

* Avoid false speech worker crash logs on shutdown
2026-06-06 21:45:33 +08:00
Mohamed Boudra
0cf1717e04 Fix auto-archive after merged PR branch deletion (#1378) 2026-06-06 21:13:16 +08:00
Mohamed Boudra
2822c02543 Keep Codex request identity vanilla (#1377) 2026-06-06 21:00:25 +08:00
Mohamed Boudra
db4376d17a Prepare prompt file attachments for future UI (#1376)
* Support file uploads to agents

* Harden prompt file upload handling

* Fix duplicate upload retry handling

* Update relay reconnect tests for queued messages

* Make upload timeout track idle progress

* Clean failed upload directories
2026-06-06 20:37:38 +08:00
Mohamed Boudra
9a8912b3ef Open browser links in workspace tabs (#1375)
* Open browser window requests as workspace tabs

* Tighten browser new-tab request boundaries
2026-06-06 19:30:21 +08:00
paseo-ai[bot]
fd022bc44b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-05 16:13:31 +00:00
Mohamed Boudra
7408de6300 chore(release): cut 0.1.91-beta.2 2026-06-05 23:08:57 +07:00
Mohamed Boudra
9b21ccd7f1 Report daemon auth failures in status 2026-06-05 23:03:32 +07:00
Mohamed Boudra
20c03355f9 Default git shipping to pull requests 2026-06-05 22:56:33 +07:00
Mohamed Boudra
e09a1591ac Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 22:39:32 +07:00
Mohamed Boudra
d35bffed8b Allow $schema in config files 2026-06-05 22:38:55 +07:00
dixonl90
d454fa3af4 Fix worktree checkout validation for existing git branch refs (#1358)
* fix(server): allow valid existing worktree branch refs (#1357)

* Fix existing branch checkout validation

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 23:31:38 +08:00
Mohamed Boudra
59b32ab3be Remove epic orchestration skills 2026-06-05 22:30:53 +07:00
Fabian Fischer
44e9287389 fix(claude): preserve alwaysLoad on MCP server configs (#1333)
* fix(claude): preserve alwaysLoad on MCP server configs

Paseo's Claude provider normalizes McpServerConfig via toClaudeSdkMcpConfig
before passing it to claude-agent-sdk. The function copied type/command/args/
env (stdio) and type/url/headers (http, sse) but dropped the alwaysLoad
field for all three transports.

Without alwaysLoad making it to the SDK, every MCP server's tools were
deferred behind tool search. In practice the agent never discovered them
via search and would respond 'no chrome-devtools tools available' even
with the server fully running and connected.

Add alwaysLoad to the McpServerConfig types and zod schemas, copy it
through in toClaudeSdkMcpConfig for stdio/http/sse, and add unit tests
covering all three transports plus the default-undefined case.

Other providers ignore the field, consistent with the existing pattern
where each provider normalizes only what its CLI/SDK supports.

* test(claude): drop ternary in toClaudeSdkMcpConfig assertion

The 'alwaysLoad' field is now declared on every branch of the
discriminated union returned by toClaudeSdkMcpConfig, so the in-operator
guard is no longer needed. Direct property access expresses the intent
without embedding a conditional in the assertion.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 22:29:47 +08:00
Mohamed Boudra
c89177c211 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 21:21:17 +07:00
Mohamed Boudra
dd329a4f52 Fix stacked provider settings sheets 2026-06-05 21:15:44 +07:00
Mohamed Boudra
0d1eecc388 Keep virtualenvs out of project picker results (#1356)
* Fix project picker virtualenv suggestions

* Ignore more virtualenv names in project picker
2026-06-05 21:35:47 +08:00
Fabian Fischer
7124a82298 Authenticate file downloads with their capability token alone (#1351)
The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route.

Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured.

Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update.
2026-06-05 13:22:30 +00:00
Arielle Ostankova
df635b570a feat(desktop): support multiple windows
Closes #250
2026-06-05 21:08:24 +08:00
Matteo Pietro Dazzi
1377adbece fix(claude): respect profile models for built-in claude provider (#1311)
* fix(claude): respect profile models for built-in claude provider

The built-in Claude provider was the only one in the registry whose
profile `models` array was treated as additive on top of the hardcoded
first-party catalog. Every other built-in provider (and every custom
provider) replaces the runtime model list when `models` is set, which
matches the documented behavior in docs/custom-providers.md.

For users pointing Claude Code at a third-party Anthropic-compatible
gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating
the picker with `agents.providers.claude.models`, the old behavior
leaked the nine first-party Claude models into the dropdown, making it
impossible to ship a curated list. This is issue #1299.

The fix flips the built-in Claude entry in `provider-registry.ts` to
match the other providers, so `models` replaces the runtime catalog
(including the settings.json-discovered entries surfaced by
getClaudeModelsWithSettings). The existing `additionalModels` field
keeps its additive semantics for anyone who still wants to append
entries on top of the first-party list.

- provider-registry.ts: drop the claude-only `profileModelsAreAdditive`
  branch and align with the rest of the registry.
- provider-registry.test.ts: update the "append to runtime models"
  expectation for built-in Claude to "replace runtime models" and add a
  regression test that mirrors the issue scenario (hardcoded catalog +
  configured profile models, expect only the profile models).
- agent.test.ts: make the "returns hardcoded claude models" hermetic by
  pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading
  the host's real ~/.claude/settings.json (which now contains MiniMax
  env vars from the issue report) and leaking that into the assertion.
- custom-providers.md: correct the note about Claude profile models
  being additive and document the new replace semantics alongside
  additionalModels.

Closes #1299

* test(claude): drop explanatory comments from new tests

* refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity

Greptile flagged the previous hermeticity fix on agent.test.ts: mutating
process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into
any concurrent test in the same process for the duration of the try
block.

Thread a `configDir` option through ClaudeAgentClient ->
getClaudeModelsWithSettings -> readClaudeSettingsModels ->
resolveClaudeConfigDir instead. The constructor follows the same
injection pattern as `resolveBinary`, so the test passes an empty temp
dir via the option and no shared global state is touched.

- models.ts: add an optional `configDir` to
  getClaudeModelsWithSettings, readClaudeSettingsModels, and
  resolveClaudeConfigDir. Env var remains the fallback for production
  callers.
- agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on
  the instance, and forward it to getClaudeModelsWithSettings from
  listModels.
- agent.test.ts: drop the process.env save/mutate/restore dance and
  pass `configDir: emptyConfigDir` to the constructor instead.

Refs #1311
2026-06-05 20:58:38 +08:00
Kevin Aspesi
350bc08fc4 fix(app): make markdown links tappable on iOS (#1334)
* fix(app): make markdown links tappable on iOS

A markdown link's label renders as nested React elements
(link > textgroup > text -> MarkdownInheritedText spans). The link's
onPress was placed on the wrapping UITextView span, but
react-native-uitextview only attaches onPress to the *string* children
it converts into tappable RNUITextViewChild nodes - element children
pass through untouched, so the handler never reached a node the native
tap recognizer could fire. Link text stayed visible and selectable but
dead to taps; web links never opened.

Thread the link's press handler down to the leaf text spans via a new
AssistantLinkPressProvider so it lands on the real tappable string
nodes. MarkdownInheritedText consumes the context and forwards onPress
to its MarkdownTextSpan; null outside a link, so ordinary text is
unaffected. Gated to iOS - Android forwards onPress through nested
<Text> already, and web uses the <a> path.

* refactor(app): export useAssistantLinkPress from module index

Import it through the assistant-file-links public surface instead of
reaching into the internal link-press-context file, matching the rest of
message.tsx. Addresses Greptile review feedback on #1334.

---------

Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
2026-06-05 20:43:09 +08:00
Fabian Fischer
dfddda7969 feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346)
* feat(server): make provider refresh timeout configurable

Provider refresh probes (isAvailable + listModels/listModes) were hardcoded
to a 30s timeout. With many providers and MCP servers configured, cold
starts of agents like Copilot can exceed this on the first probe, surfacing
'Timed out refreshing Copilot after 30000ms' even though a manual retry
succeeds (warm caches).

Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the
ceiling without rebuilding. The explicit constructor option still wins
over the env var, and invalid values fall back to the 30s default.

* refactor(server): address review feedback on refresh timeout config

- Use Number() instead of Number.parseInt() so scientific notation like
  '6e4' parses as 60000 instead of being silently truncated to 6.
- Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the
  suite and avoid manual process.env save/restore.
2026-06-05 12:19:05 +00:00
Mohamed Boudra
abf129f56e Fix iOS markdown style metadata warnings
Resolve markdown UITextView styles to plain React Native text styles before crossing the third-party boundary. react-native-uitextview flattens parent and child styles internally, so Unistyles metadata must not be carried into that handoff.
2026-06-05 19:11:29 +07:00
Mohamed Boudra
45cca8a406 Fix iOS markdown image rendering 2026-06-05 14:11:17 +07:00
Mohamed Boudra
e961ceef98 Hide mobile sidebar for new workspace 2026-06-05 10:36:02 +07:00
paseo-ai[bot]
f30f217023 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 18:35:24 +00:00
Mohamed Boudra
9d3b037d4f chore(release): cut 0.1.91-beta.1 2026-06-05 01:31:13 +07:00
Mohamed Boudra
c3515d74b9 Fix Windows workspace provider loading (#1329)
* test: cover Windows provider snapshot cwd updates

* fix: normalize Windows provider snapshot paths

* fix: preserve Claude history path candidates

* fix: match Claude project paths on Windows
2026-06-05 02:29:34 +08:00
Matt Cowger
b5192e577a Stop local daemon when removing localhost (#1297)
* fix(app): stop local daemon when removing localhost

* fix(app): handle daemon removal rollback

* fix(app): return listen address in desktop e2e mock

* fix(app): refresh daemon status after registration failure

* fix(app): unslop daemon status error coverage

* fix(app): preserve daemon toggle error context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-04 22:30:40 +08:00
paseo-ai[bot]
9e2c75a8a3 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 13:41:28 +00:00
205 changed files with 8185 additions and 1675 deletions

1
.gitignore vendored
View File

@@ -64,6 +64,7 @@ CLAUDE.local.md
.debug.conversations/
.debug/
.dev/
.paseo/
.wrangler/
**/.wrangler/

View File

@@ -3,6 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",

View File

@@ -1,5 +1,46 @@
# Changelog
## 0.1.91 - 2026-06-08
### Added
- **Open multiple desktop windows** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost))
- **Open browser pop-ups and links inside workspace tabs** ([#1375](https://github.com/getpaseo/paseo/pull/1375))
- **Use the command center from mobile**
- **Add OMP as a provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388))
### Improved
- New workspaces remember your last provider, mode, and thinking choices
- Git controls now default ready branches to pull requests and hide unavailable pull or push actions
- Desktop-managed hosts recover more reliably after stale daemon state
- Daemon status now explains authentication failures
- Project search skips Python virtual environments ([#1356](https://github.com/getpaseo/paseo/pull/1356))
- Config files can include `$schema` for editor help
- Claude MCP servers preserve always-load tool settings ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain))
- Claude profiles keep their configured models ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood))
- Provider loading can wait longer on slow machines ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain))
- The Kimi catalog entry now points to Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000))
- ACP provider catalog entries are updated for Auggie, Claude Agent, Cline, Codebuddy Code, DimCode, Factory Droid, fast-agent, Gemini, GitHub Copilot, and Nova
- Local dictation crash reports show more useful details ([#1379](https://github.com/getpaseo/paseo/pull/1379))
- Daemon logs show why managed workers exit
### Fixed
- Pi compaction slash commands run correctly ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
- Auto-archiving still works after a merged PR branch is deleted ([#1378](https://github.com/getpaseo/paseo/pull/1378))
- Worktrees can check out existing branch refs correctly ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90))
- File downloads work when daemon password protection is enabled ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain))
- iOS markdown links are tappable again ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi))
- iOS markdown images render correctly
- Windows workspaces load their providers correctly ([#1329](https://github.com/getpaseo/paseo/pull/1329))
- Removing a localhost host stops its local daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger))
- Provider settings sheets stack correctly
- The new workspace screen no longer opens behind the mobile sidebar
- Global agent listing works again ([#1420](https://github.com/getpaseo/paseo/pull/1420))
- OpenCode compaction summaries stay out of chat
- OpenCode agents sharing a workspace keep their own Paseo tools
## 0.1.90 - 2026-06-04
### Added

View File

@@ -49,7 +49,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
## Quick start
```bash
npm run dev # Start daemon + Expo in Tmux
npm run dev # Start the dev daemon
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
@@ -58,6 +60,8 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules

View File

@@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows.
- Native file access for workspace integration
- Same WebSocket client as mobile app
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
@@ -232,7 +238,7 @@ 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 built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |

View File

@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
@@ -347,6 +347,41 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
```json
{
"agents": {
"providers": {
"omp": { "enabled": true }
}
}
}
```
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
{
"agents": {
"providers": {
"my-pi-fork": {
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
}
}
}
}
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
## Disabling a provider
@@ -364,7 +399,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
---
@@ -577,7 +612,7 @@ Each entry in the `models` array:
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
@@ -604,7 +639,7 @@ Use `disallowedTools` to disable unsupported tools:
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
Special value: `acp` — creates a generic ACP provider (requires `command`)

View File

@@ -8,18 +8,27 @@
## Running the dev server
```bash
npm run dev
npm run dev:server
npm run dev:app
npm run dev:desktop
```
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
Root checkout dev is intentionally split across terminals:
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
Override knobs:
@@ -32,16 +41,19 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
### Daemon endpoints
- Stable daemon launched by the desktop app: `localhost:6767`.
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
- Root checkout dev daemon: `localhost:6768`.
- Root checkout Expo: `http://localhost:8081`.
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
- `npm run dev` (Windows): `localhost:6767` for the daemon.
In any worktree-style or portless setup, never assume default ports.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
@@ -185,7 +197,7 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
@@ -223,7 +235,7 @@ install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
```bash
npm run cli -- ls -a -g # List all agents globally
@@ -277,7 +289,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.

View File

@@ -169,6 +169,11 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
Do not treat `onChange(-1)` as a close by itself. In a stacked
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Recipe for a new anchored panel
Before you write a new one, ask:

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/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).

View File

@@ -70,7 +70,7 @@ Anyone who builds software:
## Current state (May 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)

View File

@@ -14,7 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
@@ -24,6 +24,8 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
@@ -262,7 +264,7 @@ case "my-provider":
);
```
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
```ts
export const allProviders: AgentProvider[] = [

View File

@@ -1 +1 @@
sha256-MEG/ywKGhzW6D2iw9KIodi6wKpClWYxy++Mba+79Kt8=
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.90",
"version": "0.1.91",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.90",
"version": "0.1.91",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36953,7 +36953,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -37178,12 +37178,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.90",
"@getpaseo/protocol": "0.1.90",
"@getpaseo/server": "0.1.90",
"@getpaseo/client": "0.1.91",
"@getpaseo/protocol": "0.1.91",
"@getpaseo/server": "0.1.91",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37429,10 +37429,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@getpaseo/protocol": "0.1.90",
"@getpaseo/relay": "0.1.90",
"@getpaseo/protocol": "0.1.91",
"@getpaseo/relay": "0.1.91",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -37452,7 +37452,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.90",
"version": "0.1.91",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37704,7 +37704,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.90",
"version": "0.1.91",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37740,7 +37740,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37971,7 +37971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37992,7 +37992,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38210,14 +38210,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.90",
"@getpaseo/highlight": "0.1.90",
"@getpaseo/protocol": "0.1.90",
"@getpaseo/relay": "0.1.90",
"@getpaseo/client": "0.1.91",
"@getpaseo/highlight": "0.1.91",
"@getpaseo/protocol": "0.1.91",
"@getpaseo/relay": "0.1.91",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38989,7 +38989,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.90",
"version": "0.1.91",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.90",
"version": "0.1.91",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -34,12 +34,12 @@
"packages/cli"
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev": "npm run dev:server",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -82,11 +82,11 @@
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",

View File

@@ -1,4 +1,5 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -6,6 +7,8 @@ import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
interface WaitForServerOptions {
@@ -188,7 +191,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeToolBinDir: string | null = null;
let fakeEditorBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,81 +212,24 @@ interface OfferPayload {
relay: { endpoint: string };
}
async function createFakeToolBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function findRealGh() {
const fakeBinDir = __dirname;
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir === fakeBinDir) continue;
const candidate = path.join(dir, "gh");
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
}
return null;
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory: NodeWebSocketFactory;
}
function forwardToRealGh() {
const realGh = findRealGh();
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
process.exit(result.status ?? 1);
interface PairingDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonPairingOffer(): Promise<{
relayEnabled: boolean;
url: string;
}>;
}
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "api" && args[1] === "graphql") {
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
forwardToRealGh();
`,
);
await chmod(ghPath, 0o755);
async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
@@ -343,29 +289,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload;
}
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
const stdout = execFileSync(
process.execPath,
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
{
cwd: repoRoot,
env: {
...process.env,
PASEO_HOME: paseoHomePath,
},
encoding: "utf8",
},
);
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
throw new Error(`Unexpected daemon pair response: ${stdout}`);
async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
clientId: `playwright-global-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
try {
const pairing = await client.getDaemonPairingOffer();
if (!pairing.relayEnabled || !pairing.url) {
throw new Error("Daemon returned a disabled pairing offer");
}
return decodeOfferFromFragmentUrl(pairing.url);
} finally {
await client.close().catch(() => {});
}
return decodeOfferFromFragmentUrl(payload.url);
}
async function waitForPairingOfferFromCli(args: {
repoRoot: string;
paseoHome: string;
async function waitForPairingOfferFromDaemon(args: {
port: number;
timeoutMs?: number;
}): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000;
@@ -374,7 +320,7 @@ async function waitForPairingOfferFromCli(args: {
while (Date.now() - start < timeoutMs) {
try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
return await loadPairingOfferFromDaemon(args.port);
} catch (error) {
lastError = error;
await sleep(100);
@@ -382,7 +328,7 @@ async function waitForPairingOfferFromCli(args: {
}
throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
`Timed out waiting for daemon pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
@@ -629,7 +575,7 @@ interface DaemonSpawnArgs {
relayPort: number;
metroPort: number;
paseoHome: string;
fakeToolBinDir: string;
fakeEditorBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
@@ -644,7 +590,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
@@ -710,9 +656,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeToolBinDir) {
await rm(fakeToolBinDir, { recursive: true, force: true });
fakeToolBinDir = null;
if (fakeEditorBinDir) {
await rm(fakeEditorBinDir, { recursive: true, force: true });
fakeEditorBinDir = null;
}
}
@@ -727,7 +673,7 @@ export default async function globalSetup() {
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeToolBinDir = await createFakeToolBin();
fakeEditorBinDir = await createFakeEditorBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -745,7 +691,7 @@ export default async function globalSetup() {
relayPort,
metroPort,
paseoHome,
fakeToolBinDir,
fakeEditorBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
@@ -765,9 +711,8 @@ export default async function globalSetup() {
}),
]);
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
const offer = await waitForPairingOfferFromDaemon({
port,
});
process.env.E2E_DAEMON_PORT = String(port);

View File

@@ -124,7 +124,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: null,
listen: "127.0.0.1:6767",
hostname: null,
pid: currentPid,
home: "",

View File

@@ -28,6 +28,7 @@ import {
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
import {
expectSidebarWorkspaceSelected,
@@ -707,10 +708,16 @@ test.describe("New workspace flow", () => {
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
const ghRepo = await createTempGithubRepo({
category: "new-workspace-pr-ref",
prs: [{ title: "Review selected start ref", state: "open" }],
});
const pr = ghRepo.prs[0]!;
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
const openedProject = await openProjectViaDaemon(client, pr.localPath);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
@@ -720,19 +727,19 @@ test.describe("New workspace flow", () => {
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await selectGitHubPrInPicker(page, pr.number);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
number: pr.number,
title: pr.title,
headRef: pr.branch,
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
number: pr.number,
title: pr.title,
});
} finally {
await tempRepo.cleanup();
await ghRepo.cleanup();
}
});
});

View File

@@ -0,0 +1,109 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function openProviderSettingsFromModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
if (await openCodeRow.isVisible().catch(() => false)) {
await openCodeRow.click();
}
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
}
async function expectModelSelectorVisible(page: Page) {
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
timeout: 10_000,
});
}
async function closeTopSheet(page: Page) {
const closeTarget = page.getByLabel("Close", { exact: true }).last();
if (await closeTarget.isVisible().catch(() => false)) {
await closeTarget.click({ force: true });
return;
}
const handle = page.getByRole("slider", { name: "Bottom sheet handle" }).last();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error("Bottom sheet handle was not measurable");
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
}
async function exerciseProviderSettingsStack(page: Page) {
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
}
test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await closeTopSheet(page);
} finally {
await session.cleanup();
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.90",
"version": "0.1.91",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -1,4 +1,5 @@
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
@@ -931,17 +932,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
// PortalProvider must remain the innermost global provider here.
// PortalProvider must stay inside normal app-wide context providers here.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
// Adding a new global provider? Put it above PortalProvider.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>{children}</PortalProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</QueryProvider>

View File

@@ -3,6 +3,7 @@ export {
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { TextProps } from "react-native";
// Carries a link's press handler down to the leaf text spans that render its
// label. On iOS an assistant link is a nested UITextView span, and
// react-native-uitextview only attaches onPress to the *string* children it
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
// MarkdownInheritedText spans markdown produces for link text) pass through
// untouched, so an onPress placed on the wrapping span never reaches a tappable
// native node. Threading the handler through context lets each leaf span hand
// onPress to its own string children, where the native tap recognizer can find
// it. Provided only on iOS (Android/web links tap fine via their own paths).
export interface AssistantLinkPress {
onPress: () => void;
accessibilityRole?: TextProps["accessibilityRole"];
}
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
export function useAssistantLinkPress(): AssistantLinkPress | null {
return useContext(AssistantLinkPressContext);
}

View File

@@ -1,5 +1,6 @@
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Platform,
Pressable,
Text,
View,
@@ -10,6 +11,7 @@ import {
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -56,23 +58,42 @@ export function AssistantMarkdownLink({
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
);
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
// inside the paragraph's native UITextView, and a plain <Text> nested there
// is not hoisted into a UITextViewChild, so its text is silently dropped
// (the link disappears). The span composes correctly and stays selectable;
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
// (the link disappears). The span composes correctly and stays selectable.
//
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
// children it turns into RNUITextViewChild nodes — the element children that
// markdown emits for link text pass through untouched, so an onPress placed
// here never reaches a tappable native node. We thread it down through
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
// own string children, where the native tap recognizer can find it. iOS
// only: Android forwards onPress through nested <Text> already, and web uses
// the <a> path below.
const span = (
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
);
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
{Platform.OS === "ios" ? (
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
) : (
span
)}
</FileLinkHoverTooltip>
);
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { createDefaultLayout } from "@/stores/workspace-layout-store";
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
return openTabInLayoutFocused({
layout: createDefaultLayout(),
target: { kind: "browser", browserId },
now: 1,
}).layout;
}
describe("browser new-tab requests", () => {
it("accepts desktop requests from browser tabs in the current workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toEqual<BrowserNewTabRequest>({
sourceBrowserId: "browser-1",
url: "https://example.com/target",
});
});
it("ignores desktop requests from another workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-from-other-workspace",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
it("rejects unsupported desktop request URLs", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "file:///etc/passwd",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
});

View File

@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import { getIsElectron } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
function isAllowedBrowserNewTabUrl(value: string): boolean {
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
if (!payload || typeof payload !== "object") {
return null;
}
const candidate = payload as Partial<BrowserNewTabRequest>;
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
return null;
}
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
return null;
}
return {
sourceBrowserId: candidate.sourceBrowserId,
url: candidate.url,
};
}
function workspaceContainsBrowser(input: {
workspaceLayout: WorkspaceLayout | null | undefined;
browserId: string;
}): boolean {
if (!input.workspaceLayout) {
return false;
}
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
});
}
export function resolveBrowserNewTabRequest(input: {
payload: unknown;
workspaceLayout: WorkspaceLayout | null | undefined;
}): BrowserNewTabRequest | null {
const request = readDesktopBrowserNewTabRequest(input.payload);
if (!request) {
return null;
}
if (
!workspaceContainsBrowser({
workspaceLayout: input.workspaceLayout,
browserId: request.sourceBrowserId,
})
) {
return null;
}
return request;
}
export function useDesktopBrowserNewTabRequests(input: {
enabled: boolean;
workspaceLayout: WorkspaceLayout | null | undefined;
openUrl: (url: string) => void;
}): void {
const handleNewTabRequest = useStableEvent((payload: unknown) => {
const request = resolveBrowserNewTabRequest({
payload,
workspaceLayout: input.workspaceLayout,
});
if (!request) {
return;
}
input.openUrl(request.url);
});
useEffect(() => {
if (!input.enabled || !getIsElectron()) {
return;
}
const unsubscribe = getDesktopHost()?.events?.on?.(
"browser-new-tab-request",
handleNewTabRequest,
);
if (typeof unsubscribe === "function") {
return unsubscribe;
}
return () => {
void unsubscribe?.then((dispose) => dispose());
};
}, [handleNewTabRequest, input.enabled]);
}

View File

@@ -12,6 +12,7 @@ import {
BottomSheetTextInput,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
@@ -232,14 +233,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
() => [
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
],
[style, theme.colors.surface1, theme.borderRadius.xl],
[style, theme.colors.surface0, theme.borderRadius],
);
return <View style={combinedStyle} />;
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
export type AdaptiveTextInputProps = TextInputProps & {
@@ -444,6 +445,7 @@ export interface AdaptiveModalSheetProps {
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
scrollable?: boolean;
presentation?: "push" | "replace";
}
export function AdaptiveModalSheet({
@@ -457,6 +459,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
onFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -501,8 +504,8 @@ export function AdaptiveModalSheet({
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
@@ -543,6 +546,7 @@ export function AdaptiveModalSheet({
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (

View File

@@ -685,10 +685,6 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -714,11 +710,10 @@ export function CombinedModelSelector({
setSearchQuery(value);
}, []);
const openHeaderProviderSettings = useCallback(() => {
const openProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
handleClose();
}, [serverId, view, handleClose]);
}, [serverId, view]);
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
@@ -727,7 +722,7 @@ export function CombinedModelSelector({
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openHeaderProviderSettings}
onPress={openProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
@@ -760,7 +755,7 @@ export function CombinedModelSelector({
view,
singleProviderView,
serverId,
openHeaderProviderSettings,
openProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,

View File

@@ -9,24 +9,39 @@ import {
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
} from "@gorhom/bottom-sheet";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
}
const CommandCenterRow = memo(function CommandCenterRow({
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
children,
onPress,
registerRow,
onLayout,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
);
return (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children}
</Pressable>
);
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode;
}
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
active,
rowRefs,
onPress,
onLayout,
children,
}: CommandCenterRowContainerProps) {
const registerRow = useCallback(
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
[rowRefs, rowIndex],
);
return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={onPress}
onLayout={onLayout}
>
{children}
</CommandCenterRow>
);
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
}
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
item,
children,
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRowContainer>
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
actionItemsLength: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
@@ -219,6 +251,7 @@ function AgentItemsSection({
actionItemsLength,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
@@ -237,6 +270,7 @@ function AgentItemsSection({
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
@@ -249,50 +283,119 @@ function AgentItemsSection({
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null);
const nativeScrollY = useRef(0);
const nativeViewHeight = useRef(0);
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bottomSheetInputRef = useRef<any>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: showBottomSheet,
onClose: handleClose,
});
// Focus the bottom sheet input when the sheet opens on mobile
useEffect(() => {
if (!open) {
return;
if (showBottomSheet && open) {
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
}
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
}
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
}, [showBottomSheet, open]);
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
// Scroll active row into view
useEffect(() => {
if (!open) return;
if (isWeb) {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") return;
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
// Native: use onLayout-measured positions
const layout = rowLayouts.current.get(activeIndex);
if (!layout || !resultsRef.current) return;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
} else if (rowBottom > visibleBottom) {
resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
animated: true,
});
}
}, [activeIndex, open]);
const handleRowLayout = useCallback(
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
rowLayouts.current.set(rowIndex, {
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
},
[],
);
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
@@ -324,8 +427,101 @@ export function CommandCenter() {
[theme.colors.border],
);
if (isNative || !open) return null;
const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
handleKeyEvent(key);
},
[handleKeyEvent],
);
const handleSubmitEditing = useCallback(() => {
handleKeyEvent("Enter");
}, [handleKeyEvent]);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
);
// Mobile: bottom sheet
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef as unknown as React.Ref<never>}
value={query}
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..."
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
);
}
if (!open) return null;
// Desktop web: centered overlay panel
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
@@ -354,39 +550,7 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
)}
{resultList}
</ScrollView>
</View>
</View>
@@ -395,6 +559,12 @@ export function CommandCenter() {
}
const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
overlay: {
flex: 1,
justifyContent: "flex-start",

View File

@@ -34,6 +34,7 @@ import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
@@ -395,7 +396,11 @@ function createFilePreviewMarkdownRules(): RenderRules {
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
<MarkdownParagraphView
key={node.key}
paragraphStyle={styles.paragraph}
containsImage={markdownNodeContainsType(node, "image")}
>
{children}
</MarkdownParagraphView>
),

View File

@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
});
it("returns only enabled importable providers", () => {
it("returns enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude" },
{ provider: "codex" },
{ provider: "opencode", enabled: false },
{ provider: "z-ai" },
]);
expect(providers).toEqual(["claude", "codex"]);
expect(providers).toEqual(["claude", "codex", "z-ai"]);
});
it("returns an empty array when snapshot has no enabled importable providers", () => {
it("returns an empty array when snapshot has no enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude", enabled: false },
{ provider: "z-ai" },
{ provider: "z-ai", enabled: false },
]);
expect(providers).toEqual([]);
});

View File

@@ -1,8 +1,6 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
@@ -28,9 +26,7 @@ export function resolveProvidersToFetch(
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
return snapshotEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.provider);
}
export function buildProviderLabelMap(

View File

@@ -541,7 +541,7 @@ describe("ImportSessionSheet", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
it("fans out one request per enabled provider when snapshot is supported", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => ({
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
@@ -591,12 +591,15 @@ describe("ImportSessionSheet", () => {
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["opencode"] }),
);
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["z-ai"] }),
);
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["z-ai"],
limit: 15,
});
await screen.findByText("Session claude");
await screen.findByText("Session codex");
await screen.findByText("Session z-ai");
});
it("shows partial-failure note when one provider request fails but others succeed", async () => {
@@ -716,7 +719,7 @@ describe("ImportSessionSheet", () => {
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
it("shows a no-importable-providers message when snapshot has no enabled providers", async () => {
const fetchRecentProviderSessions = vi.fn();
const importAgent = vi.fn();
@@ -732,7 +735,7 @@ describe("ImportSessionSheet", () => {
createSnapshotEntry("claude", { enabled: false }),
createSnapshotEntry("codex", { enabled: false }),
createSnapshotEntry("opencode", { enabled: false }),
createSnapshotEntry("z-ai"),
createSnapshotEntry("z-ai", { enabled: false }),
],
},
},

View File

@@ -103,6 +103,7 @@ interface SidebarSharedProps {
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -118,7 +119,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
}
@@ -229,6 +230,11 @@ export const LeftSidebar = memo(function LeftSidebar({
void openProjectPicker();
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
@@ -297,7 +303,8 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={showMobileAgent}
closeSidebar={showMobileAgent}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
@@ -311,6 +318,7 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
@@ -554,13 +562,14 @@ function MobileSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
insetsBottom,
isOpen,
closeToAgent,
closeSidebar,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
@@ -581,8 +590,8 @@ function MobileSidebar({
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
closeSidebar();
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
@@ -590,20 +599,25 @@ function MobileSidebar({
}
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
closeSidebar();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
closeToAgent,
closeSidebar,
handleViewMoreNavigate,
translateX,
windowWidth,
]);
const handleWorkspacePress = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
closeSidebar();
}, [closeSidebar]);
const handleNewWorkspace = useCallback(() => {
closeSidebar();
handleNewWorkspaceNavigate();
}, [closeSidebar, handleNewWorkspaceNavigate]);
const closeGesture = useMemo(
() =>
@@ -745,10 +759,13 @@ function MobileSidebar({
testID="sidebar-sessions"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspace}
/>
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
@@ -826,6 +843,7 @@ function DesktopSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
@@ -913,7 +931,10 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspaceNavigate}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -958,17 +979,17 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
function WorkspacesSectionHeader({
serverId,
onNewWorkspacePress,
}: {
serverId: string | null;
onNewWorkspacePress: () => void;
}) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const handleNewWorkspacePress = useCallback(() => {
if (!serverId) {
return;
}
router.push(buildHostNewWorkspaceRoute(serverId));
}, [serverId]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
@@ -988,7 +1009,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
accessibilityLabel="New workspace"
testID="sidebar-new-workspace"
style={searchButtonStyle}
onPress={handleNewWorkspacePress}
onPress={onNewWorkspacePress}
>
{({ hovered, pressed }) => (
<Plus

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { StyleSheet, type TextStyle } from "react-native";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
function unistylesStyle(id: string, style: Record<string, unknown>) {
return {
...style,
[`unistyles_${id}`]: { id },
};
}
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
// before passing the result to its native View-backed components.
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
}
function unistylesMetadataKeys(style: Record<string, unknown>) {
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
}
describe("resolvePlainMarkdownTextStyle", () => {
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
const merged = uiTextViewFlatten(
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
);
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
});
});

View File

@@ -0,0 +1,25 @@
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
}
function stripUnistylesMetadata(style: TextStyle): TextStyle {
// iOS markdown text goes through react-native-uitextview. That library
// inherits text styles by flattening [parentStyle, childStyle] before handing
// the result to native View-backed components. If both entries are Unistyles
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
// and Unistyles correctly warns that the style should have stayed array-shaped.
//
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
// our ownership model. Resolve the concrete style values before crossing that
// boundary and drop only Unistyles' private tracking metadata. This preserves
// iOS paragraph/spanning text selection while avoiding the metadata merge.
const plainStyle: Record<string, unknown> = { ...style };
for (const key of Object.keys(plainStyle)) {
if (key.startsWith("unistyles_")) {
delete plainStyle[key];
}
}
return plainStyle as TextStyle;
}

View File

@@ -35,6 +35,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}

View File

@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from "react";
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
import { UITextView } from "react-native-uitextview";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
@@ -8,10 +9,11 @@ interface MarkdownTextSpanProps {
children: ReactNode;
// Links route through this span too (see assistant-file-links/link.tsx). A
// plain <Text> nested in the paragraph UITextView is dropped, so the link
// must be a UITextView span to be visible. onPress is forwarded best-effort:
// react-native-uitextview nulls onPress on the root native view, so reliable
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
// invisible link.
// must be a UITextView span to be visible. onPress is wired onto the leaf
// string children here: react-native-uitextview attaches it to the
// RNUITextViewChild nodes it builds from string content, which the native tap
// recognizer dispatches to. The link's handler reaches these leaf spans via
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
@@ -26,11 +28,13 @@ export function MarkdownTextSpan({
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
return (
<UITextView
uiTextView
selectable
style={style}
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
@@ -41,6 +45,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
@@ -52,13 +57,27 @@ const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// ViewStyle is structurally compatible with the layout props paragraphs use
// (margin, padding, alignment); the cast lets the existing paragraphStyle
// flow through unchanged.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
export function MarkdownParagraphView({
paragraphStyle,
containsImage = false,
children,
}: MarkdownParagraphViewProps) {
const textStyle = useMemo(
() =>
resolvePlainMarkdownTextStyle([
paragraphStyle,
MARKDOWN_PARAGRAPH_RESET,
] as StyleProp<TextStyle>),
[paragraphStyle],
);
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
if (containsImage) {
return <View style={viewStyle}>{children}</View>;
}
return (
<UITextView uiTextView selectable style={style}>
<UITextView uiTextView selectable style={textStyle}>
{children}
</UITextView>
);

View File

@@ -46,6 +46,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}

View File

@@ -70,6 +70,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
@@ -99,6 +100,7 @@ import {
AssistantMarkdownLink,
type InlinePathTarget,
useAssistantFileLinkActions,
useAssistantLinkPress,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -1531,8 +1533,19 @@ function MarkdownInheritedText({
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
// When this span renders link label text on iOS, pick up the link's press
// handler from context and hand it to MarkdownTextSpan, which forwards it to
// the leaf string children react-native-uitextview makes tappable. Null
// outside a link (and on every other platform, where no provider mounts), so
// ordinary text is unaffected. See assistant-file-links/link-press-context.
const linkPress = useAssistantLinkPress();
return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
<MarkdownTextSpan
monoSurface={monoSurface}
style={style}
onPress={linkPress?.onPress}
accessibilityRole={linkPress?.accessibilityRole}
>
{children}
</MarkdownTextSpan>
);
@@ -1825,7 +1838,11 @@ export const AssistantMessage = memo(function AssistantMessage({
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
<MarkdownParagraphView
key={node.key}
paragraphStyle={styles.paragraph}
containsImage={markdownNodeContainsType(node, "image")}
>
{children}
</MarkdownParagraphView>
),

View File

@@ -196,6 +196,7 @@ function AddCustomModelSubSheet({
onClose={onClose}
desktopMaxWidth={420}
snapPoints={ADD_SNAP_POINTS}
testID="add-custom-model-sheet"
>
<View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text>
@@ -339,6 +340,7 @@ function DiagnosticSubSheet({
onClose={onClose}
snapPoints={DIAGNOSTIC_SNAP_POINTS}
scrollable={false}
testID="provider-diagnostic-sheet"
>
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
</AdaptiveModalSheet>
@@ -616,6 +618,7 @@ export function ProviderDiagnosticSheet({
header={sheetHeader}
visible={visible}
onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAtLabel,
isCompact,

View File

@@ -5,6 +5,7 @@ describe("resolveProviderIconName", () => {
it("returns the built-in identifier for known provider ids", () => {
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {

View File

@@ -1,6 +1,13 @@
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
export type BuiltinProviderIconName =
| "claude"
| "codex"
| "copilot"
| "kiro"
| "omp"
| "opencode"
| "pi";
export type ProviderIconName =
| { kind: "builtin"; id: BuiltinProviderIconName }
@@ -12,6 +19,7 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
"codex",
"copilot",
"kiro",
"omp",
"opencode",
"pi",
]);

View File

@@ -24,6 +24,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
omp: PiIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};

View File

@@ -5,6 +5,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
const visible = useProviderSettingsStore((state) => state.visible);
const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => {
@@ -19,7 +20,7 @@ export function ProviderSettingsHost() {
<ProviderDiagnosticSheet
provider={provider}
serverId={serverId}
visible
visible={visible}
onClose={handleClose}
/>
);

View File

@@ -117,7 +117,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useSessionStore } from "@/stores/session-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import {
@@ -129,7 +128,12 @@ import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import {
isWeb as platformIsWeb,
isNative as platformIsNative,
getIsElectron,
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
@@ -154,14 +158,24 @@ const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const redColorMapping = (theme: Theme) => ({
color: theme.colors.palette.red[500],
});
const amberColorMapping = (theme: Theme) => ({
color: theme.colors.palette.amber[500],
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.palette.green[500],
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.palette.purple[500],
});
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
@@ -516,6 +530,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -533,6 +548,9 @@ const markAsReadLeadingIcon = (
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
@@ -545,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
const handleOpenInNewWindow = useCallback(() => {
const trimmedPath = projectPath.trim();
if (trimmedPath.length === 0) return;
void getDesktopHost()
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error("Couldn't open a new window");
});
}, [projectPath, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -578,6 +613,15 @@ function ProjectKebabMenu({
Open project settings
</DropdownMenuItem>
) : null}
{canOpenInNewWindow ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
Open in new window
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
@@ -1172,17 +1216,14 @@ function ProjectHeaderRow({
if (!serverId) {
return;
}
onWorkspacePress?.();
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
displayName,
projectId: project.projectKey,
}) as Href,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast();
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -2402,7 +2443,10 @@ function SidebarStatusModeWrapper({
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspaces = useStatusModeWorkspaceEntries({ serverId, projects });
const hydratedWorkspaces = useStatusModeWorkspaceEntries({
serverId,
projects,
});
const projectNamesByKey = useProjectNamesMap(serverId);
const showShortcutBadges = useShowShortcutBadges();
@@ -2463,7 +2507,10 @@ function ProjectModeList({
[parentGestureRef],
);
const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
const projectIconByProjectKey = useProjectIconDataByProjectKey({
serverId,
projects,
});
useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current;

View File

@@ -95,6 +95,7 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
@@ -965,6 +966,7 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
presentation?: "push" | "replace";
searchResetKey: number;
searchPlaceholder: string;
searchQuery: string;
@@ -1024,6 +1026,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
handleIndicatorStyle={props.handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="none"
presentation={props.presentation}
>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
@@ -1248,6 +1251,7 @@ export function Combobox({
title = "Select",
header,
mobileChildrenScrollEnabled = true,
presentation,
open,
onOpenChange,
desktopPlacement = "top-start",
@@ -1534,6 +1538,7 @@ export function Combobox({
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
presentation={presentation}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery}

View File

@@ -0,0 +1,76 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@gorhom/portal", () => ({
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
React.createElement("div", { "data-portal-host": hostName }, children),
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
}));
vi.mock("@gorhom/bottom-sheet", () => ({
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
(
{
children,
stackBehavior,
}: {
children?: React.ReactNode;
stackBehavior?: string;
},
_ref,
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
}));
import { IsolatedBottomSheetModal } from ".";
describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
</>,
);
});
expect(
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
node.getAttribute("data-stack-behavior"),
),
).toEqual(["push", "push"]);
});
it("only replaces when the callsite asks for replacement", () => {
act(() => {
root.render(
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
);
});
expect(
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
).toBe("replace");
});
});

View File

@@ -1,10 +1,8 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps,
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef } from "react";
import {
@@ -17,50 +15,29 @@ type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior"
>;
> & {
presentation?: "push" | "replace";
};
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
const IsolatedBottomSheetScopeContext = createContext(false);
export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
const { children, ...bottomSheetProps } = props;
const scopedChildren =
typeof children === "function" ? (
(input: { data?: unknown }) => (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children(input) as React.ReactNode}
</IsolatedBottomSheetScopeContext.Provider>
)
) : (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children}
</IsolatedBottomSheetScopeContext.Provider>
);
const { children, presentation = "push", ...bottomSheetProps } = props;
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
enableDismissOnClose
stackBehavior={isNestedSheet ? "push" : "replace"}
stackBehavior={presentation}
>
{scopedChildren}
{children}
</GorhomBottomSheetModal>
);
if (isNestedSheet) {
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
return modal;
});
export function useIsolatedBottomSheetVisibility({

View File

@@ -70,17 +70,17 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([]);
});
it("only reports a user close when the sheet was visible", () => {
it("does not treat index -1 as a close because stacked sheets can be hidden without dismissing", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
});
it("reports a dismiss while visible as a close request", () => {
@@ -93,7 +93,7 @@ describe("bottom sheet visibility tracker", () => {
expect(closeCount()).toBe(1);
});
it("deduplicates close notifications from change and dismiss callbacks", () => {
it("reports close once when a hidden sheet is actually dismissed", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
@@ -110,12 +110,14 @@ describe("bottom sheet visibility tracker", () => {
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(1);
tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(2);
});
@@ -128,7 +130,7 @@ describe("bottom sheet visibility tracker", () => {
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
expect(sheet.events).toEqual([{ type: "present" }]);
});

View File

@@ -69,7 +69,7 @@ export function createBottomSheetVisibilityTracker(opts: {
},
handleSheetIndexChange(index) {
if (index !== -1) {
if (phase === "presenting") {
if (phase === "presenting" || phase === "dismissing") {
phase = "presented";
}
return;
@@ -77,9 +77,6 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
}
if (visible) {
notifyClose();
}
},
handleSheetDismiss() {
if (visible) {

View File

@@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
@@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
onBeforeSubmit?.({
await onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,

View File

@@ -441,8 +441,8 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
}),
onBeforeSubmit: () => {
void composerState.persistFormPreferences();
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { CreateAgentPreferencesService } from "./service";
import {
mergeCreateAgentSelectionPreferences,
mergeProviderPreferences,
parseFormPreferences,
} from "./preferences";
import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage";
describe("create agent preferences", () => {
it("keeps the selected mode after saving model and thinking", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const modelWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } },
}),
);
await storage.nextWrite();
const modeWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { mode: "full-access" },
}),
);
expect(storage.pendingWriteCount()).toBe(1);
storage.finishOldestWrite();
await modelWrite;
await storage.nextWrite();
storage.finishOldestWrite();
await modeWrite;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
thinkingByModel: { "gpt-5.5": "high" },
mode: "full-access",
},
},
});
});
it("flushes the full create-agent selection into provider preferences", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const saveSelection = preferences.update((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: "codex",
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
await storage.nextWrite();
storage.finishOldestWrite();
await saveSelection;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
featureValues: { fast_mode: true },
},
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
});

View File

@@ -0,0 +1,147 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): FormPreferences {
const result = formPreferencesSchema.safeParse(value);
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function mergeCreateAgentSelectionPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider | null;
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
}
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: {
model: modelId || undefined,
mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(args.featureValues ? { featureValues: args.featureValues } : {}),
},
});
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}

View File

@@ -0,0 +1,62 @@
import { AsyncStorageCreateAgentPreferenceStorage } from "./storage";
import {
DEFAULT_FORM_PREFERENCES,
parseFormPreferences,
type FormPreferences,
} from "./preferences";
import type { CreateAgentPreferenceStorage } from "./storage";
export type FormPreferenceUpdate =
| Partial<FormPreferences>
| ((current: FormPreferences) => FormPreferences);
export class CreateAgentPreferencesService {
private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES;
private isLoaded = false;
private loadPromise: Promise<FormPreferences> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: CreateAgentPreferenceStorage) {}
async load(): Promise<FormPreferences> {
if (this.isLoaded) {
return this.preferences;
}
if (!this.loadPromise) {
this.loadPromise = this.storage.read().then((stored) => {
this.preferences = parseFormPreferences(stored);
this.isLoaded = true;
return this.preferences;
});
}
return this.loadPromise;
}
async update(update: FormPreferenceUpdate): Promise<FormPreferences> {
const previousWrite = this.writeQueue;
const operation = this.applyQueuedUpdate(previousWrite, update);
this.writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
private async applyQueuedUpdate(
previousWrite: Promise<void>,
update: FormPreferenceUpdate,
): Promise<FormPreferences> {
await previousWrite;
const current = await this.load();
const next = typeof update === "function" ? update(current) : { ...current, ...update };
this.preferences = parseFormPreferences(next);
this.isLoaded = true;
await this.storage.write(this.preferences);
return this.preferences;
}
}
export const createAgentPreferencesService = new CreateAgentPreferencesService(
new AsyncStorageCreateAgentPreferenceStorage(),
);

View File

@@ -0,0 +1,28 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { FormPreferences } from "./preferences";
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
export interface CreateAgentPreferenceStorage {
read(): Promise<unknown>;
write(preferences: FormPreferences): Promise<void>;
}
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> {
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
if (!stored) {
return null;
}
try {
return JSON.parse(stored);
} catch {
return null;
}
}
async write(preferences: FormPreferences): Promise<void> {
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
}
}

View File

@@ -0,0 +1,65 @@
import type { FormPreferences } from "../preferences";
import type { CreateAgentPreferenceStorage } from "../storage";
interface PendingWrite {
preferences: FormPreferences;
finish: () => void;
}
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
private stored: unknown;
private readonly pendingWrites: PendingWrite[] = [];
private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = [];
constructor(input: { stored?: unknown } = {}) {
this.stored = input.stored ?? null;
}
async read(): Promise<unknown> {
return this.stored;
}
write(preferences: FormPreferences): Promise<void> {
return new Promise((resolve) => {
const write = {
preferences,
finish: () => {
this.stored = clone(preferences);
resolve();
},
};
this.pendingWrites.push(write);
this.pendingWriteWaiters.shift()?.(write);
});
}
nextWrite(): Promise<PendingWrite> {
const next = this.pendingWrites[0];
if (next) {
return Promise.resolve(next);
}
return new Promise((resolve) => {
this.pendingWriteWaiters.push(resolve);
});
}
pendingWriteCount(): number {
return this.pendingWrites.length;
}
finishOldestWrite(): void {
const write = this.pendingWrites.shift();
if (!write) {
throw new Error("No pending create-agent preference write");
}
write.finish();
}
savedPreferences(): unknown {
return this.stored;
}
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@@ -36,10 +36,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.28.0",
version: "0.29.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.28.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.29.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -57,29 +57,29 @@ const CATALOG_DATA = [
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.41.0",
version: "0.42.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.41.0"],
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.42.0"],
},
{
id: "cline",
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.17",
version: "3.0.20",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.17", "--acp"],
command: ["npx", "-y", "cline@3.0.20", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.103.0",
version: "2.103.4",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.4", "--acp"],
},
{
id: "codex-acp",
@@ -157,10 +157,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.0.77",
version: "0.1.0",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.0.77", "acp"],
command: ["npx", "-y", "dimcode@0.1.0", "acp"],
},
{
id: "dirac",
@@ -176,10 +176,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.140.0",
version: "0.142.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.140.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.142.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -189,28 +189,28 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.15",
version: "0.7.16",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.15", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.16", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.45.0",
version: "0.45.2",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.45.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.45.2", "--acp"],
},
{
id: "github-copilot-cli",
title: "GitHub Copilot",
description: "GitHub's AI pair programmer",
version: "1.0.59",
version: "1.0.60",
iconId: "github-copilot-cli",
installLink: "https://github.com/features/copilot/cli/",
command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"],
command: ["npx", "-y", "@github/copilot@1.0.60", "--acp"],
},
{
id: "glm-acp-agent",
@@ -279,11 +279,11 @@ const CATALOG_DATA = [
},
{
id: "kimi",
title: "Kimi CLI",
description: "Moonshot AI's coding assistant",
version: "1.41.0",
title: "Kimi Code CLI",
description: "Moonshot AI's open-source terminal coding agent",
version: "0.11.0",
iconId: "kimi",
installLink: "https://github.com/MoonshotAI/kimi-cli",
installLink: "https://github.com/MoonshotAI/kimi-code",
command: ["kimi", "acp"],
},
{
@@ -309,10 +309,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.14",
version: "1.1.15",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.15", "acp"],
},
{
id: "opencode",

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
DaemonConnectionRegistrationError,
DaemonManagementOperationError,
getDaemonManagementErrorPresentation,
} from "./daemon-management-error";
describe("getDaemonManagementErrorPresentation", () => {
it("refreshes status when the daemon started but localhost registration failed", () => {
const presentation = getDaemonManagementErrorPresentation(
new DaemonConnectionRegistrationError("Desktop daemon did not return a listen address."),
false,
);
expect(presentation).toEqual({
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
refreshStatus: true,
});
});
it("does not refresh status for daemon stop failures", () => {
const presentation = getDaemonManagementErrorPresentation(new Error("stop failed"), true);
expect(presentation).toEqual({
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
});
});
it("uses the pre-mutation daemon management state for operation failures", () => {
const presentation = getDaemonManagementErrorPresentation(
new DaemonManagementOperationError(new Error("stop failed"), true),
false,
);
expect(presentation).toEqual({
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
});
});
it("does not refresh status for generic update failures", () => {
const presentation = getDaemonManagementErrorPresentation(new Error("settings failed"), false);
expect(presentation).toEqual({
message: "Unable to update built-in daemon management.",
refreshStatus: false,
});
});
});

View File

@@ -0,0 +1,52 @@
export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) {
super(message);
this.name = "DaemonConnectionRegistrationError";
}
}
export class DaemonManagementOperationError extends Error {
readonly originalError: Error;
readonly wasManagingDaemon: boolean;
constructor(error: Error, wasManagingDaemon: boolean) {
super(error.message);
this.name = error.name;
this.cause = error;
this.originalError = error;
this.wasManagingDaemon = wasManagingDaemon;
}
}
export interface DaemonManagementErrorPresentation {
message: string;
refreshStatus: boolean;
}
export function getDaemonManagementErrorPresentation(
error: Error,
isManagingDaemon: boolean,
): DaemonManagementErrorPresentation {
const presentationError =
error instanceof DaemonManagementOperationError ? error.originalError : error;
const wasManagingDaemon =
error instanceof DaemonManagementOperationError ? error.wasManagingDaemon : isManagingDaemon;
if (presentationError instanceof DaemonConnectionRegistrationError) {
return {
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
refreshStatus: true,
};
}
if (wasManagingDaemon) {
return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
};
}
return {
message: "Unable to update built-in daemon management.",
refreshStatus: false,
};
}

View File

@@ -9,8 +9,15 @@ import {
executeDaemonManagementToggle,
type DaemonManagementToggleResult,
} from "@/desktop/daemon/daemon-management-toggle";
import {
DaemonConnectionRegistrationError,
DaemonManagementOperationError,
getDaemonManagementErrorPresentation,
} from "@/desktop/daemon/daemon-management-error";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service";
import { confirmDialog } from "@/utils/confirm-dialog";
type DesktopDaemonSettings = DesktopSettings["daemon"];
@@ -37,27 +44,51 @@ export function useBuiltInDaemonManagement(
DaemonManagementToggleResult,
Error
>({
mutationFn: () =>
executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
}),
mutationFn: async () => {
const wasManagingDaemon = settings.manageBuiltInDaemon;
try {
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
});
if (result.kind === "enabled") {
const upsertResult = await upsertDesktopDaemonConnection(
getHostRuntimeStore(),
result.newStatus,
);
if (!upsertResult.ok) {
throw new DaemonConnectionRegistrationError(upsertResult.error);
}
}
return result;
} catch (error) {
throw new DaemonManagementOperationError(
error instanceof Error ? error : new Error(String(error)),
wasManagingDaemon,
);
}
},
onError: (error) => {
const presentation = getDaemonManagementErrorPresentation(
error,
settings.manageBuiltInDaemon,
);
if (presentation.refreshStatus) {
refreshStatus();
}
reportError({
error,
message: settings.manageBuiltInDaemon
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
: "Unable to update built-in daemon management.",
message: presentation.message,
logLabel: "[Settings] Failed to update built-in daemon management",
});
},

View File

@@ -98,6 +98,7 @@ export interface DesktopWindowBridge {
}
export interface DesktopWindowModuleBridge {
openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise<void>;
getCurrentWindow?: () => DesktopWindowBridge;
}
@@ -110,6 +111,11 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
url: string;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;

View File

@@ -49,7 +49,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
shipDefault: "pr",
runtime: {
commit: {
disabled: false,
@@ -252,6 +252,38 @@ describe("git-actions-policy", () => {
});
});
it("keeps pull-and-push unavailable when the branch only has outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 0,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("keeps pull-and-push unavailable when the branch only has incoming commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
@@ -387,13 +419,31 @@ describe("git-actions-policy", () => {
});
});
it("promotes local merge over update-from-base", () => {
it("promotes Create PR over push and local merge when PR is the ship default", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
aheadOfOrigin: 2,
behindBaseCount: 3,
}),
);
expect(actions.primary).toMatchObject({
id: "pr",
label: "Create PR",
});
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
shipDefault: "merge",
}),
);
@@ -665,7 +715,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
false,
);
@@ -766,7 +816,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),

View File

@@ -333,9 +333,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (canMergePr(input)) {
return getDefaultDirectPullRequestMergeActionId(input);
}
@@ -345,6 +342,12 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (hasEnabledPrAutoMerge(input)) {
return "pr";
}
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
return "pr";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return "merge-branch";
}
@@ -502,6 +505,16 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
);
}
function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): boolean {
if (input.isOnBaseBranch || !input.githubFeaturesEnabled) {
return false;
}
if (input.hasPullRequest) {
return input.pullRequestUrl !== null;
}
return input.aheadCount > 0;
}
function canMergePr(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const canMergeFromPullRequestStatus =
@@ -603,6 +616,12 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if (input.aheadOfOrigin === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
}
return undefined;
}

View File

@@ -155,7 +155,7 @@ interface UseGitActionsResult {
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
@@ -186,15 +186,19 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
useEffect(() => {
if (!shipDefaultStorageKey) {
setShipDefault("pr");
return;
}
let isActive = true;
setShipDefault("pr");
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
return;
}
setShipDefault("pr");
return;
})
.catch(() => undefined);
@@ -210,7 +214,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
// Ignore persistence failures; default will reset to "pr".
}
},
[shipDefaultStorageKey],

View File

@@ -39,4 +39,13 @@ describe("providers snapshot query scope", () => {
providers: ["codex"],
});
});
it("uses one query scope for Windows cwd values with either separator", () => {
expect(normalizeProvidersSnapshotCwd("C:\\Users\\Ezekiel Bulver\\project")).toBe(
"C:/Users/Ezekiel Bulver/project",
);
expect(providersSnapshotQueryKey("server-1", "C:\\Users\\Ezekiel Bulver\\project")).toEqual(
providersSnapshotQueryKey("server-1", "C:/Users/Ezekiel Bulver/project"),
);
});
});

View File

@@ -1,10 +1,10 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
const trimmed = cwd?.trim();
return trimmed ? trimmed : null;
return normalizeWorkspacePath(cwd);
}
export function providersSnapshotQueryRoot(serverId: string | null) {

View File

@@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -319,33 +320,25 @@ export function useCommandCenter() {
}
}, [activeIndex, items.length, open]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
const currentItems = itemsRef.current;
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
if (key === "Escape") {
event.preventDefault();
handleCloseRef.current();
return;
return true;
}
if (key === "Enter") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]);
return;
return true;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
@@ -353,13 +346,35 @@ export function useCommandCenter() {
if (next >= currentItems.length) return 0;
return next;
});
return true;
}
return false;
},
[open],
);
useEffect(() => {
if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (handleKeyEvent(event.key)) {
event.preventDefault();
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open]);
}, [open, handleKeyEvent]);
return {
open,
@@ -371,5 +386,6 @@ export function useCommandCenter() {
items,
handleClose,
handleSelectItem,
handleKeyEvent,
};
}

View File

@@ -1,137 +1,35 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import {
buildFavoriteModelKey,
DEFAULT_FORM_PREFERENCES,
isFavoriteModel,
mergeProviderPreferences,
toggleFavoriteModel,
type FavoriteModelPreference,
type FavoriteModelRow,
type FormPreferences,
type ProviderPreferences,
} from "@/create-agent-preferences/preferences";
import {
createAgentPreferencesService,
type FormPreferenceUpdate,
} from "@/create-agent-preferences/service";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences };
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel };
async function loadFormPreferences(): Promise<FormPreferences> {
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
if (!stored) return DEFAULT_FORM_PREFERENCES;
const result = formPreferencesSchema.safeParse(JSON.parse(stored));
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
return createAgentPreferencesService.load();
}
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
updatePreferences: (
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
}
export function useFormPreferences(): UseFormPreferencesReturn {
@@ -146,13 +44,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback(
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
async (updates: FormPreferenceUpdate) => {
const next = await createAgentPreferencesService.update(updates);
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);

View File

@@ -250,6 +250,27 @@ describe("applyProvidersSnapshotUpdate", () => {
providersSnapshot([]),
);
});
it("applies Windows daemon updates to app-normalized workspace paths", () => {
const workspaceCwd = "C:/Users/Ezekiel Bulver/project";
const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project";
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, workspaceCwd),
providersSnapshot([codexEntry("loading")]),
);
applyProvidersSnapshotUpdate({
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd),
});
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({
entries: [codexEntry("ready", [readyCodexModel])],
generatedAt: "2026-01-01T00:00:01.000Z",
requestId: "providers_snapshot_update",
});
});
});
describe("selectorOpenRefetchDecision", () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonStartService } from "./daemon-start-service";
import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service";
import type { HostRuntimeStore } from "./host-runtime";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
@@ -223,3 +223,50 @@ describe("DaemonStartService", () => {
expect(notifications).toBe(countAfterFirst);
});
});
describe("upsertDesktopDaemonConnection", () => {
it("upserts a valid desktop daemon status", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus());
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
]);
});
it("rejects a missing listen address without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
expect(result).toEqual({
ok: false,
error: "Desktop daemon did not return a listen address.",
});
expect(fake.upserts).toEqual([]);
});
it("rejects a missing server id without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ serverId: "" }));
expect(result).toEqual({
ok: false,
error: "Desktop daemon did not return a server id.",
});
expect(fake.upserts).toEqual([]);
});
it("rejects an unsupported listen address without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: "???" }));
expect(result.ok).toBe(false);
expect(result.ok ? "" : result.error).toContain("unsupported listen address");
expect(fake.upserts).toEqual([]);
});
});

View File

@@ -4,13 +4,41 @@ import type { HostRuntimeStore } from "@/runtime/host-runtime";
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
export interface DaemonStartServiceDeps {
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
store: DaemonConnectionStore;
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
}
export async function upsertDesktopDaemonConnection(
store: DaemonConnectionStore,
daemon: DesktopDaemonStatus,
): Promise<DaemonStartResult> {
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return { ok: false, error: "Desktop daemon did not return a listen address." };
}
if (!serverId) {
return { ok: false, error: "Desktop daemon did not return a server id." };
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
await store.upsertConnectionFromListen({
listenAddress,
serverId,
hostname: daemon.hostname,
});
return { ok: true };
}
export class DaemonStartService {
private readonly store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
private readonly store: DaemonConnectionStore;
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
private readonly listeners = new Set<() => void>();
private lastError: string | null = null;
@@ -25,23 +53,8 @@ export class DaemonStartService {
this.beginRequest();
try {
const daemon = await this.invokeStartDesktopDaemon();
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return this.fail("Desktop daemon did not return a listen address.");
}
if (!serverId) {
return this.fail("Desktop daemon did not return a server id.");
}
if (!connectionFromListen(listenAddress)) {
return this.fail(`Desktop daemon returned an unsupported listen address: ${listenAddress}`);
}
await this.store.upsertConnectionFromListen({
listenAddress,
serverId,
hostname: daemon.hostname,
});
return { ok: true };
const result = await upsertDesktopDaemonConnection(this.store, daemon);
return result.ok ? result : this.fail(result.error);
} catch (error) {
return this.fail(error instanceof Error ? error.message : String(error));
} finally {

View File

@@ -747,9 +747,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
provider,
clientMessageId,
timestamp,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
@@ -1063,6 +1061,7 @@ export function NewWorkspaceScreen({
async (payload: MessagePayload) => {
try {
setErrorMessage(null);
await composerState?.persistFormPreferences();
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
await runCreateEmptyWorkspace({

View File

@@ -84,6 +84,7 @@ import {
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -133,6 +134,7 @@ interface SidebarSectionItem {
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "daemon", label: "Daemon", icon: Server, desktopOnly: true },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
{ id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true },
@@ -155,6 +157,24 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", label: "Host", icon: Server },
];
function renderHostSettingsContent(
view: Extract<SettingsView, { kind: "host" }>,
onHostRemoved: () => void,
): ReactNode {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
}
}
// ---------------------------------------------------------------------------
// Trigger + sidebar style helpers
// ---------------------------------------------------------------------------
@@ -1325,18 +1345,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => {
if (view.kind === "host") {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
return renderHostSettingsContent(view, handleHostRemoved);
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
@@ -1356,6 +1365,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "daemon":
return <LocalDaemonSection />;
case "appearance":
return <AppearanceSection />;
case "shortcuts":

View File

@@ -7,7 +7,10 @@ import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
@@ -72,7 +75,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
}
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
function useHostProfile(serverId: string): HostProfile | null {
const daemons = useHosts();
@@ -276,7 +278,7 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
}
@@ -826,11 +828,22 @@ function PairDeviceRow() {
);
}
function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: () => void }) {
function RemoveHostSection({
host,
isLocalDaemon,
onRemoved,
}: {
host: HostProfile;
isLocalDaemon: boolean;
onRemoved?: () => void;
}) {
const { theme } = useUnistyles();
const { removeHost } = useHostMutations();
const { updateSettings } = useDesktopSettings();
const { data: daemonStatusData, setStatus } = useDaemonStatus();
const [isConfirming, setIsConfirming] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const daemonStatus = daemonStatusData?.status ?? null;
const destructiveTextStyle = useMemo(
() => ({ color: theme.colors.destructive }),
@@ -843,9 +856,45 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
setIsConfirming(false);
}, [isRemoving]);
const handleCancel = useCallback(() => setIsConfirming(false), []);
const rollbackLocalhostRemoval = useCallback(
async (shouldRestartDaemon: boolean) => {
await updateSettings({ daemon: { manageBuiltInDaemon: true } });
if (!shouldRestartDaemon) {
return;
}
setStatus(await startDesktopDaemon());
},
[setStatus, updateSettings],
);
const handleConfirmRemove = useCallback(() => {
setIsRemoving(true);
void removeHost(host.serverId)
const remove = async () => {
let didDisableDaemonManagement = false;
let didStopDaemon = false;
if (isLocalDaemon) {
try {
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
didDisableDaemonManagement = true;
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
setStatus(await stopDesktopDaemon());
didStopDaemon = true;
}
await removeHost(host.serverId);
} catch (error) {
if (didDisableDaemonManagement) {
try {
await rollbackLocalhostRemoval(didStopDaemon);
} catch (rollbackError) {
console.error("[HostPage] Failed to roll back localhost removal", rollbackError);
}
}
throw error;
}
return;
}
await removeHost(host.serverId);
};
void remove()
.then(() => {
setIsConfirming(false);
onRemoved?.();
@@ -853,10 +902,29 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
Alert.alert(
"Error",
isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host",
);
})
.finally(() => setIsRemoving(false));
}, [host.serverId, onRemoved, removeHost]);
}, [
daemonStatus,
host.serverId,
isLocalDaemon,
onRemoved,
removeHost,
rollbackLocalhostRemoval,
setStatus,
updateSettings,
]);
const confirmationHeader = useMemo<SheetHeader>(
() => ({
title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host",
}),
[isLocalDaemon],
);
const removeIcon = useMemo(
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
@@ -870,9 +938,13 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text>
<Text style={settingsStyles.rowTitle}>
{isLocalDaemon ? "Remove localhost connection" : "Remove host"}
</Text>
<Text style={settingsStyles.rowHint}>
Removes this host and its saved connections from this device
{isLocalDaemon
? "Removes localhost from this device and stops the built-in daemon"
: "Removes this host and its saved connections from this device"}
</Text>
</View>
<Button
@@ -890,13 +962,15 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
{isConfirming ? (
<AdaptiveModalSheet
header={REMOVE_HOST_HEADER}
header={confirmationHeader}
visible
onClose={handleCloseConfirm}
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
Remove {host.label}? This will delete its saved connections.
{isLocalDaemon
? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected."
: `Remove ${host.label}? This will delete its saved connections.`}
</Text>
<View style={styles.confirmActions}>
<Button

View File

@@ -130,6 +130,7 @@ import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuEntry,
} from "@/screens/workspace/workspace-tab-menu";
import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
resolveWorkspaceHeaderRenderState,
@@ -2329,6 +2330,12 @@ function WorkspaceScreenContent({
[openWorkspaceTabFocused, persistenceKey],
);
useDesktopBrowserNewTabRequests({
enabled: Boolean(persistenceKey),
workspaceLayout,
openUrl: handleOpenUrlInBrowserTab,
});
const handleSelectSwitcherTab = useCallback(
(key: string) => {
navigateToTabId(key);

View File

@@ -8,6 +8,7 @@ interface ProviderSettingsTarget {
interface ProviderSettingsStoreState {
serverId: string | null;
provider: string | null;
visible: boolean;
open: (target: ProviderSettingsTarget) => void;
close: () => void;
}
@@ -15,10 +16,11 @@ interface ProviderSettingsStoreState {
export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({
serverId: null,
provider: null,
visible: false,
open: ({ serverId, provider }) => {
set({ serverId, provider });
set({ serverId, provider, visible: true });
},
close: () => {
set({ serverId: null, provider: null });
set({ visible: false });
},
}));

View File

@@ -99,6 +99,21 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
};
}
function compactionTimeline(
status: "loading" | "completed",
trigger?: "auto" | "manual",
): AgentStreamEventPayload {
return {
type: "timeline",
provider: "pi",
item: {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
},
};
}
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
return state.find(
(item): item is AgentToolCallItem =>
@@ -693,6 +708,27 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[1]?.completed, true);
});
it("preserves compaction trigger when completed update replaces loading marker", () => {
const state = hydrateStreamState([
{
event: compactionTimeline("loading", "auto"),
timestamp: new Date("2025-01-01T10:50:00Z"),
},
{
event: compactionTimeline("completed"),
timestamp: new Date("2025-01-01T10:50:01Z"),
},
]);
const compactions = state.filter(
(item): item is Extract<StreamItem, { kind: "compaction" }> => item.kind === "compaction",
);
assert.strictEqual(compactions.length, 1);
assert.strictEqual(compactions[0].status, "completed");
assert.strictEqual(compactions[0].trigger, "auto");
});
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
const state = hydrateStreamState([
{

View File

@@ -726,8 +726,8 @@ function reduceTimelineCompaction(
const updated: CompactionItem = {
...existing,
status: "completed",
trigger: item.trigger,
preTokens: item.preTokens,
trigger: item.trigger ?? existing.trigger,
preTokens: item.preTokens ?? existing.preTokens,
};
return [...state.slice(0, loadingIdx), updated, ...state.slice(loadingIdx + 1)];
}

View File

@@ -383,6 +383,7 @@ export function buildHostNewWorkspaceRoute(
export const SETTINGS_SECTION_SLUGS = [
"general",
"daemon",
"appearance",
"shortcuts",
"integrations",

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { markdownNodeContainsType } from "./markdown-ast";
describe("markdownNodeContainsType", () => {
it("matches the node itself", () => {
expect(markdownNodeContainsType({ type: "image", children: [] }, "image")).toBe(true);
});
it("matches descendants", () => {
const paragraph = {
type: "paragraph",
children: [
{ type: "text", children: [] },
{
type: "link",
children: [{ type: "image", children: [] }],
},
],
};
expect(markdownNodeContainsType(paragraph, "image")).toBe(true);
});
it("returns false when the type is absent", () => {
const paragraph = {
type: "paragraph",
children: [
{ type: "text", children: [] },
{ type: "strong", children: [{ type: "text", children: [] }] },
],
};
expect(markdownNodeContainsType(paragraph, "image")).toBe(false);
});
});

View File

@@ -0,0 +1,12 @@
export interface MarkdownAstNodeWithChildren {
type: string;
children: MarkdownAstNodeWithChildren[];
}
export function markdownNodeContainsType(node: MarkdownAstNodeWithChildren, type: string): boolean {
if (node.type === type) {
return true;
}
return node.children.some((child) => markdownNodeContainsType(child, type));
}

View File

@@ -20,6 +20,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
pi: {
resume: "pi --session {sessionId}",
},
omp: {
resume: "omp --session {sessionId}",
},
opencode: {
resume: "opencode --session {sessionId}",
},

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.90",
"version": "0.1.91",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.90",
"@getpaseo/protocol": "0.1.90",
"@getpaseo/server": "0.1.90",
"@getpaseo/client": "0.1.91",
"@getpaseo/protocol": "0.1.91",
"@getpaseo/server": "0.1.91",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -5,15 +5,11 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
import { agentRunSchema, type AgentRunResult } from "./run.js";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
export function addImportOptions(cmd: Command): Command {
return cmd
.description("Import an existing provider session as a Paseo agent")
.argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
.requiredOption("--provider <provider>", "Agent provider id")
.option("--cwd <path>", "Working directory for providers that require it")
.option(
"--label <key=value>",
@@ -52,14 +48,6 @@ function parseImportProvider(provider: string | undefined): string {
} satisfies CommandError;
}
if (!IMPORT_PROVIDERS.has(normalizedProvider)) {
throw {
code: "INVALID_PROVIDER",
message: `Unsupported provider: ${normalizedProvider}`,
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
} satisfies CommandError;
}
return normalizedProvider;
}

View File

@@ -23,11 +23,39 @@ describe("buildAgentLsFetchOptions", () => {
});
});
it("uses the unscoped archived query for -a", () => {
it("fetches global non-archived agents for -g", () => {
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
});
it("keeps -a within the active scope", () => {
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
scope: "active",
filter: {
includeArchived: true,
},
});
});
it("fetches all global agents for -a -g", () => {
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
filter: {
includeArchived: true,
},
});
});
it("applies filters to global queries", () => {
expect(
buildAgentLsFetchOptions({
global: true,
label: ["surface=workspace"],
thinking: " medium ",
}),
).toEqual({
filter: {
labels: { surface: "workspace" },
thinkingOptionId: "medium",
},
});
});
});

View File

@@ -13,7 +13,7 @@ export function addLsOptions(cmd: Command): Command {
return cmd
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option("-g, --global", "List agents across all directories")
.option(
"--label <key=value>",
"Filter by label (can be used multiple times)",
@@ -107,7 +107,7 @@ export type AgentLsResult = ListResult<AgentListItem>;
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean;
/** Legacy flag retained for CLI compatibility */
/** -g: List agents across all directories */
global?: boolean;
/** Filter by specific status */
status?: string;
@@ -133,7 +133,7 @@ function parseLabelFilters(labels: string[] | undefined): Record<string, string>
}
export function buildAgentLsFetchOptions(
options: Pick<AgentLsOptions, "all" | "label" | "thinking">,
options: Pick<AgentLsOptions, "all" | "global" | "label" | "thinking">,
): FetchAgentsOptions {
const labelFilters = parseLabelFilters(options.label);
const normalizedThinkingOptionId = options.thinking?.trim();
@@ -150,7 +150,7 @@ export function buildAgentLsFetchOptions(
}
const fetchOptions: FetchAgentsOptions = {};
if (!options.all) {
if (!options.global) {
fetchOptions.scope = "active";
}
if (Object.keys(daemonFilter).length > 0) {
@@ -162,7 +162,9 @@ export function buildAgentLsFetchOptions(
/**
* Agent ls command semantics:
* - `paseo agent ls` → active non-archived agents
* - `paseo agent ls -a` → include archived agents
* - `paseo agent ls -g` → global non-archived agents
* - `paseo agent ls -a` → active agents, including archived
* - `paseo agent ls -ag` → global agents, including archived
*/
export async function runLsCommand(
options: AgentLsOptions,

View File

@@ -1,5 +1,5 @@
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
@@ -351,6 +351,123 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
return poll();
}
async function waitForDaemonUnreachable(
state: LocalDaemonState,
timeoutMs: number,
): Promise<boolean> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return true;
}
const reachableHost = host;
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
if (!client) {
return true;
}
await client.close().catch(() => undefined);
if (Date.now() >= deadline) {
const finalClient = await tryConnectToDaemon({
host: reachableHost,
timeout: PID_POLL_INTERVAL_MS,
});
if (!finalClient) {
return true;
}
await finalClient.close().catch(() => undefined);
return false;
}
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
function removeStalePidFile(state: LocalDaemonState): void {
if (!state.stalePidFile) {
return;
}
try {
unlinkSync(state.pidPath);
} catch {
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
}
}
function createNotRunningStopResult(
state: LocalDaemonState,
pid: number | null,
message: string,
): StopLocalDaemonResult {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message,
};
}
function createStopTimeoutError(
state: LocalDaemonState,
pid: number | null,
timeoutMs: number,
): Error {
if (!state.running) {
const host = resolveTcpHostFromListen(state.listen);
return new Error(
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
timeoutMs / 1000,
)}s`,
);
}
return new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
async function signalDaemonOwnerForStop(
state: LocalDaemonState,
pid: number | null,
): Promise<StopLocalDaemonResult | null> {
if (pid === null) {
return createNotRunningStopResult(state, null, "Daemon is not running");
}
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (signaled) {
return null;
}
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
}
async function waitForStopAfterRequest(args: {
state: LocalDaemonState;
pid: number | null;
timeoutMs: number;
killTimeoutMs: number;
force?: boolean;
}): Promise<{ stopped: boolean; forced: boolean }> {
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
let stopped =
state.running && pid !== null
? await waitForPidExit(pid, timeoutMs)
: await waitForDaemonUnreachable(state, timeoutMs);
if (!stopped && force && state.running && pid !== null) {
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
return { stopped, forced: true };
}
return { stopped, forced: false };
}
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
@@ -567,49 +684,41 @@ export async function stopLocalDaemon(
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return {
action: "not_running",
home: state.home,
pid: state.pidInfo?.pid ?? null,
forced: false,
message: `Daemon is not running${staleSuffix}`,
};
}
const pid = state.pidInfo.pid;
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const lifecycleRequested = shutdownAttempt.requested;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
let forced = false;
if (!lifecycleRequested) {
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (!signaled) {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message: "Daemon process was already stopped",
};
}
}
let stopped = await waitForPidExit(pid, timeoutMs);
if (!stopped && options.force) {
forced = true;
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
}
if (!stopped) {
throw new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return createNotRunningStopResult(
state,
state.pidInfo?.pid ?? null,
`Daemon is not running${staleSuffix}`,
);
}
const pid = state.pidInfo?.pid ?? null;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
if (!lifecycleRequested) {
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
if (notRunningResult) return notRunningResult;
}
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs,
killTimeoutMs,
force: options.force,
});
if (!stopped) {
throw createStopTimeoutError(state, pid, timeoutMs);
}
if (lifecycleRequested) {
removeStalePidFile(state);
}
return {
action: "stopped",
home: state.home,

View File

@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
@@ -16,7 +16,7 @@ interface ProviderBinaryStatus {
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed";
connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed";
home: string;
listen: string;
relay: string;
@@ -33,6 +33,7 @@ interface DaemonStatus {
daemonVersion: string | null;
desktopManaged: boolean;
providers: ProviderBinaryStatus[];
agentsUnavailableReason?: string;
note?: string;
}
@@ -96,7 +97,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow";
if (item.value === "not_probed" || item.value === "auth_required") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
@@ -139,7 +140,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
} else {
rows.push({
key: "Agents",
value: "Unavailable (daemon API not reachable)",
value: `Unavailable (${status.agentsUnavailableReason ?? "daemon API not reachable"})`,
});
}
@@ -218,16 +219,49 @@ interface DaemonProbeResult {
idleAgents?: number;
daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[];
agentsUnavailableReason?: string;
note?: string;
}
type DaemonAuthProbeFailure = "auth_required" | "auth_failed";
function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null {
if (!(error instanceof Error)) return null;
if (error.message === "Password required") return "auth_required";
if (error.message === "Incorrect password") return "auth_failed";
return null;
}
function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") {
return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`;
}
return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`;
}
function describeAgentsUnavailableReason(failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") return "password required";
return "incorrect password";
}
async function probeDaemonOverWebsocket(args: {
host: string;
state: ReturnType<typeof resolveLocalDaemonState>;
}): Promise<DaemonProbeResult> {
const { host, state } = args;
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (!client) {
let client: Awaited<ReturnType<typeof connectToDaemon>>;
try {
client = await connectToDaemon({ host, timeout: 1500 });
} catch (error) {
const authFailure = classifyDaemonAuthProbeFailure(error);
if (authFailure) {
return {
connectedDaemon: authFailure,
agentsUnavailableReason: describeAgentsUnavailableReason(authFailure),
note: describeDaemonAuthProbeFailure(host, authFailure),
};
}
if (state.running) {
return {
connectedDaemon: "unreachable",
@@ -309,6 +343,7 @@ interface ProbeMergeState {
runningAgents: number | null;
idleAgents: number | null;
daemonProviders: ProviderBinaryStatus[] | undefined;
agentsUnavailableReason: string | undefined;
note: string | undefined;
}
@@ -322,6 +357,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
agentsUnavailableReason: probe.agentsUnavailableReason ?? input.agentsUnavailableReason,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
@@ -371,6 +407,7 @@ export async function runStatusCommand(
let idleAgents: number | null = null;
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let agentsUnavailableReason: string | undefined;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -388,6 +425,7 @@ export async function runStatusCommand(
runningAgents,
idleAgents,
daemonProviders,
agentsUnavailableReason,
note,
} = applyProbeToStatus({
probe,
@@ -398,6 +436,7 @@ export async function runStatusCommand(
runningAgents,
idleAgents,
daemonProviders,
agentsUnavailableReason,
note,
}));
} else {
@@ -434,6 +473,7 @@ export async function runStatusCommand(
daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
agentsUnavailableReason,
note,
};

View File

@@ -18,7 +18,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: "Enabled",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));

View File

@@ -50,6 +50,8 @@ try {
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("-g"), "help should mention -g flag");
assert(result.stdout.includes("--global"), "help should mention --global flag");
assert(result.stdout.includes("across all directories"), "help should describe global scope");
assert(!result.stdout.includes("Legacy no-op"), "help should not describe -g as a no-op");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(!result.stdout.includes("--ui"), "help should not mention --ui");
console.log("✓ paseo ls --help shows options\n");

View File

@@ -200,10 +200,16 @@ try {
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
assert(
data.every((p: ProviderListRow) => p.enabled === "Enabled"),
"enabled providers should report Enabled",
);
const rows = data as ProviderListRow[];
for (const provider of ["claude", "codex", "opencode"] as const) {
const row = rows.find((p) => p.provider === provider);
assert(row, `should include ${provider}`);
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
}
const omp = rows.find((p) => p.provider === "omp");
assert(omp, "should include omp");
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
console.log("✓ provider ls --json outputs valid JSON\n");
}

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestPaseoDaemon } from "../../server/src/server/test-utils/paseo-daemon.ts";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log("=== Daemon Status Auth ===\n");
const CORRECT_PASSWORD_HASH = "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76";
const daemon = await createTestPaseoDaemon({
auth: { password: CORRECT_PASSWORD_HASH },
});
try {
await writeFile(
join(daemon.paseoHome, "paseo.pid"),
`${JSON.stringify(
{
pid: process.pid,
startedAt: new Date().toISOString(),
hostname: "status-auth-test",
uid: process.getuid?.(),
listen: `0.0.0.0:${daemon.port}`,
},
null,
2,
)}\n`,
);
{
console.log("Test 1: status reports password requirement without marking daemon unreachable");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "",
});
assert.strictEqual(result.exitCode, 0, "status should still succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "auth_required");
assert.strictEqual(status.runningAgents, null);
assert.strictEqual(status.idleAgents, null);
assert.match(status.note, /requires a password/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ missing password reports auth_required\n");
}
{
console.log("Test 2: status reports rejected supplied password separately");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "wrong-secret",
});
assert.strictEqual(result.exitCode, 0, "status should still succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "auth_failed");
assert.match(status.note, /password was rejected/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ wrong password reports auth_failed\n");
}
{
console.log("Test 3: status reaches the same daemon when password is supplied");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "shared-secret",
});
assert.strictEqual(result.exitCode, 0, "status should succeed with password");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "reachable");
assert.strictEqual(status.runningAgents, 0);
assert.strictEqual(status.idleAgents, 0);
console.log("✓ password-authenticated status remains reachable\n");
}
} finally {
await daemon.close();
}
console.log("=== Daemon Status Auth Tests Passed ===");

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env npx tsx
/**
* Regression: `paseo daemon stop` must stop a reachable daemon even when the
* local pid file points at a dead supervisor owner.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false;
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface DaemonStatus {
localDaemon: string | null;
connectedDaemon: string | null;
pid: number | null;
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { localDaemon: null, connectedDaemon: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as {
localDaemon?: unknown;
connectedDaemon?: unknown;
pid?: unknown;
};
return {
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
connectedDaemon: typeof parsed.connectedDaemon === "string" ? parsed.connectedDaemon : null,
pid:
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null,
};
} catch {
return { localDaemon: null, connectedDaemon: null, pid: null };
}
}
function findUnusedPid(): number {
for (let pid = 999_999; pid > 900_000; pid--) {
if (!isProcessRunning(pid)) {
return pid;
}
}
throw new Error("Unable to find unused pid for stale pid fixture");
}
console.log("=== Daemon Stop (stale pid, reachable worker regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-stale-reachable-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
const pidPath = join(paseoHome, "paseo.pid");
const stalePid = findUnusedPid();
let workerProcess: ChildProcess | null = null;
try {
console.log("Test 1: start daemon worker with stale supervisor pid file");
await writeFile(
pidPath,
`${JSON.stringify(
{
pid: stalePid,
startedAt: new Date().toISOString(),
hostname: "stale-supervisor-fixture.local",
uid: typeof process.getuid === "function" ? process.getuid() : undefined,
listen: host,
},
null,
2,
)}\n`,
);
workerProcess = spawn(
process.execPath,
["--import", "tsx", "../server/src/server/daemon-worker.ts"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable";
},
120000,
"daemon did not enter stale_pid + reachable state in time",
);
const statusBeforeStop = await readDaemonStatus(paseoHome);
assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid");
assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running");
console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`);
console.log(
"Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running",
);
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
const stopJson = JSON.parse(stopResult.stdout) as {
action?: unknown;
pid?: unknown;
message?: unknown;
};
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
assert.strictEqual(
stopJson.pid,
String(stalePid),
"stop should report the stale pid it recovered from",
);
assert.strictEqual(
stopJson.message,
"Daemon stopped gracefully",
"stop should route through lifecycle shutdown",
);
await waitFor(
() => !isProcessRunning(workerProcess?.pid ?? -1),
15000,
"worker remained running after stop",
);
assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop");
console.log("✓ stop recovered stale supervisor pid state\n");
} finally {
if (workerProcess?.pid && isProcessRunning(workerProcess.pid)) {
workerProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(workerProcess!.pid ?? -1),
5000,
"worker cleanup timed out",
).catch(() => {
workerProcess?.kill("SIGKILL");
});
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Stale reachable stop regression test passed ===");

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env npx tsx
/**
* Regression: a supervised daemon worker must exit when its supervisor IPC
* channel closes, instead of becoming an orphaned daemon.
*/
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false;
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null;
}
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null;
}
for (const line of result.stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [pidToken, ppidToken] = trimmed.split(/\s+/);
const pid = Number.parseInt(pidToken ?? "", 10);
const ppid = Number.parseInt(ppidToken ?? "", 10);
if (ppid === supervisorPid && pid > 0) {
return pid;
}
}
return null;
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface DaemonStatus {
localDaemon: string | null;
pid: number | null;
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { localDaemon: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
return {
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
pid:
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null,
};
} catch {
return { localDaemon: null, pid: null };
}
}
console.log("=== Daemon Worker Supervisor Disconnect Regression ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-worker-supervisor-disconnect-"));
const cliRoot = join(import.meta.dirname, "..");
let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log("Test 1: start supervised daemon with isolated PASEO_HOME");
supervisorProcess = spawn(
process.execPath,
["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
supervisorProcess.stderr?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return (
status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
);
},
120000,
"daemon did not become running in time",
);
const statusBeforeKill = await readDaemonStatus(paseoHome);
const supervisorPid = statusBeforeKill.pid;
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
const workerPid = readWorkerPid(supervisorPid);
assert(workerPid !== null, "supervisor should have a worker process");
assert(isProcessRunning(workerPid), "worker process should be running");
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPid}\n`);
console.log("Test 2: killing supervisor should make worker exit via IPC disconnect");
supervisorProcess.kill("SIGKILL");
await waitFor(
() => !isProcessRunning(supervisorPid),
15000,
"supervisor remained running after SIGKILL",
);
await waitFor(
() => !isProcessRunning(workerPid),
15000,
"worker remained running after supervisor IPC disconnect",
);
console.log("✓ worker exited after supervisor disconnect\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill("SIGKILL");
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
if (recentSupervisorLogs.trim().length === 0) {
console.log("(no supervisor logs captured)");
}
console.log("=== Worker supervisor disconnect regression test passed ===");

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.90",
"version": "0.1.91",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.90",
"@getpaseo/relay": "0.1.90",
"@getpaseo/protocol": "0.1.91",
"@getpaseo/relay": "0.1.91",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
} from "@getpaseo/protocol/binary-frames/index";
@@ -103,6 +104,12 @@ function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string
return data;
}
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
throw new Error("Expected binary frame");
}
function parseSentFrame(
data: string | Uint8Array | ArrayBuffer | undefined,
): Record<string, unknown> {
@@ -567,6 +574,110 @@ test("readFile resolves from binary file frames when the daemon supports them",
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("uploadFile sends metadata request and file bytes as binary chunks", 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 responsePromise = client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
chunkSize: 5,
});
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
},
});
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
{
opcode: FileTransferOpcode.FileBegin,
requestId: "req-upload",
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
payload: new Uint8Array(),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode(" worl"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("d"),
},
{
opcode: FileTransferOpcode.FileEnd,
requestId: "req-upload",
payload: new Uint8Array(),
},
]);
mock.triggerMessage(
wrapSessionMessage({
type: "file.upload.response",
payload: {
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
},
}),
);
await expect(responsePromise).resolves.toEqual({
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
});
});
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
const logger = createMockLogger();
const mock = createMockTransport();

View File

@@ -22,6 +22,7 @@ import type {
CreateAgentRequestMessage,
CreatePaseoWorktreeRequest,
FileDownloadTokenResponse,
FileUploadResponse,
FileExplorerResponse,
FetchAgentTimelineResponseMessage,
GitSetupOptions,
@@ -90,6 +91,7 @@ import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import {
asUint8Array,
decodeFileTransferFrame,
encodeFileTransferFrame,
decodeTerminalStreamFrame,
FileTransferOpcode,
TerminalStreamOpcode,
@@ -318,6 +320,15 @@ export interface FileReadResult {
kind: LegacyFileExplorerFilePayload["kind"];
modifiedAt: string;
}
export interface FileUploadInput {
fileName: string;
mimeType: string;
bytes: Uint8Array | ArrayBuffer;
modifiedAt?: string;
requestId?: string;
chunkSize?: number;
}
export type FileUploadResult = FileUploadResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
@@ -3316,6 +3327,63 @@ export class DaemonClient {
}
}
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
const bytes = asUint8Array(input.bytes);
if (!bytes) {
throw new Error("File bytes are required.");
}
const resolvedRequestId = this.createRequestId(input.requestId);
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
const responsePromise = this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message: {
type: "file.upload.request",
fileName: input.fileName,
mimeType: input.mimeType,
size: bytes.byteLength,
modifiedAt,
requestId: resolvedRequestId,
},
responseType: "file.upload.response",
timeout: 60000,
options: { skipQueue: true },
});
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: resolvedRequestId,
metadata: {
mime: input.mimeType,
size: bytes.byteLength,
encoding: "binary",
modifiedAt,
fileName: input.fileName,
},
}),
);
const chunkSize = input.chunkSize ?? 1024 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: resolvedRequestId,
payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
}),
);
}
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: resolvedRequestId,
}),
);
return responsePromise;
}
async requestDownloadToken(
cwd: string,
path: string,

View File

@@ -7,3 +7,6 @@ node_modules/
# TypeScript
*.tsbuildinfo
# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh
.dev/

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.90",
"version": "0.1.91",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

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