mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211f5b4141 | ||
|
|
2293e0965a | ||
|
|
7c68985cc0 | ||
|
|
899849cdd5 | ||
|
|
409d67ef65 | ||
|
|
b0954d1616 | ||
|
|
9d9f6905c6 | ||
|
|
b04ff0e6b8 | ||
|
|
a6d0046c97 | ||
|
|
a1fbc91163 | ||
|
|
7d748436f4 | ||
|
|
afbb2e8bb8 | ||
|
|
6524677960 | ||
|
|
09a1fe46fe | ||
|
|
06a8f952db | ||
|
|
893e3376b0 | ||
|
|
7c8b290e2f | ||
|
|
d55e1622cb | ||
|
|
69715a77e9 | ||
|
|
e1b27fc584 | ||
|
|
9c16bc474b | ||
|
|
59d48d235a | ||
|
|
6aa73baaab | ||
|
|
0cf1717e04 | ||
|
|
2822c02543 | ||
|
|
db4376d17a | ||
|
|
9a8912b3ef | ||
|
|
fd022bc44b | ||
|
|
7408de6300 | ||
|
|
9b21ccd7f1 | ||
|
|
20c03355f9 | ||
|
|
e09a1591ac | ||
|
|
d35bffed8b | ||
|
|
d454fa3af4 | ||
|
|
59b32ab3be | ||
|
|
44e9287389 | ||
|
|
c89177c211 | ||
|
|
dd329a4f52 | ||
|
|
0d1eecc388 | ||
|
|
7124a82298 | ||
|
|
df635b570a | ||
|
|
1377adbece | ||
|
|
350bc08fc4 | ||
|
|
dfddda7969 | ||
|
|
abf129f56e | ||
|
|
45cca8a406 | ||
|
|
e961ceef98 | ||
|
|
f30f217023 | ||
|
|
9d3b037d4f | ||
|
|
c3515d74b9 | ||
|
|
b5192e577a | ||
|
|
9e2c75a8a3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,6 +64,7 @@ CLAUDE.local.md
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.dev/
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-MEG/ywKGhzW6D2iw9KIodi6wKpClWYxy++Mba+79Kt8=
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -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",
|
||||
|
||||
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
109
packages/app/e2e/provider-settings-refresh.spec.ts
Normal file
109
packages/app/e2e/provider-settings-refresh.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
|
||||
24
packages/app/src/assistant-file-links/link-press-context.ts
Normal file
24
packages/app/src/assistant-file-links/link-press-context.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
54
packages/app/src/browser/new-tab-requests/index.test.ts
Normal file
54
packages/app/src/browser/new-tab-requests/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
99
packages/app/src/browser/new-tab-requests/index.ts
Normal file
99
packages/app/src/browser/new-tab-requests/index.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
32
packages/app/src/components/markdown-text-style.test.ts
Normal file
32
packages/app/src/components/markdown-text-style.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
25
packages/app/src/components/markdown-text-style.ts
Normal file
25
packages/app/src/components/markdown-text-style.ts
Normal 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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
147
packages/app/src/create-agent-preferences/preferences.ts
Normal file
147
packages/app/src/create-agent-preferences/preferences.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
62
packages/app/src/create-agent-preferences/service.ts
Normal file
62
packages/app/src/create-agent-preferences/service.ts
Normal 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(),
|
||||
);
|
||||
28
packages/app/src/create-agent-preferences/storage.ts
Normal file
28
packages/app/src/create-agent-preferences/storage.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
52
packages/app/src/desktop/daemon/daemon-management-error.ts
Normal file
52
packages/app/src/desktop/daemon/daemon-management-error.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ export function buildHostNewWorkspaceRoute(
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"general",
|
||||
"daemon",
|
||||
"appearance",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
|
||||
35
packages/app/src/utils/markdown-ast.test.ts
Normal file
35
packages/app/src/utils/markdown-ast.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/app/src/utils/markdown-ast.ts
Normal file
12
packages/app/src/utils/markdown-ast.ts
Normal 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));
|
||||
}
|
||||
@@ -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}",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(", ") : "-",
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
92
packages/cli/tests/34-daemon-status-auth.test.ts
Normal file
92
packages/cli/tests/34-daemon-status-auth.test.ts
Normal 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 ===");
|
||||
207
packages/cli/tests/34-daemon-stop-stale-reachable.test.ts
Normal file
207
packages/cli/tests/34-daemon-stop-stale-reachable.test.ts
Normal 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 ===");
|
||||
@@ -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 ===");
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
packages/desktop/.gitignore
vendored
3
packages/desktop/.gitignore
vendored
@@ -7,3 +7,6 @@ node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh
|
||||
.dev/
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user