mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3448e8b023 | ||
|
|
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 |
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -577,7 +577,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
|
||||
|
||||
|
||||
+23
-11
@@ -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:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-MEG/ywKGhzW6D2iw9KIodi6wKpClWYxy++Mba+79Kt8=
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/server": "0.1.91-beta.2",
|
||||
"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-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.90",
|
||||
"@getpaseo/relay": "0.1.90",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/highlight": "0.1.91-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"@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-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"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",
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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-beta.2",
|
||||
"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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 @@ 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({
|
||||
|
||||
+7
-5
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/server": "0.1.91-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ===");
|
||||
@@ -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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"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,
|
||||
|
||||
@@ -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-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const desktopDir = path.resolve(scriptDir, "..");
|
||||
const rootDir = path.resolve(desktopDir, "../..");
|
||||
const appDir = path.resolve(desktopDir, "../app");
|
||||
const require = createRequire(import.meta.url);
|
||||
const electron = require("electron");
|
||||
|
||||
const expoPort = Number(process.env.EXPO_PORT);
|
||||
if (!Number.isInteger(expoPort) || expoPort <= 0) {
|
||||
console.error("[dev] EXPO_PORT must be set before running desktop dev");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`;
|
||||
const daemonEndpoint = process.env.PASEO_DAEMON_ENDPOINT || process.env.PASEO_LISTEN || "";
|
||||
const colorEnv = {
|
||||
FORCE_COLOR: process.env.FORCE_COLOR || "1",
|
||||
npm_config_color: process.env.npm_config_color || "always",
|
||||
};
|
||||
|
||||
const children = new Map();
|
||||
let stopping = false;
|
||||
let exitCode = 0;
|
||||
|
||||
function prefixStream(name, stream, target) {
|
||||
let buffered = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split(/\r?\n/);
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
target.write(line ? `[${name}] ${line}\n` : `[${name}]\n`);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (buffered) {
|
||||
target.write(`[${name}] ${buffered}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function spawnChild(name, command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...options,
|
||||
});
|
||||
|
||||
children.set(name, child);
|
||||
prefixStream(name, child.stdout, process.stdout);
|
||||
prefixStream(name, child.stderr, process.stderr);
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`[${name}] failed to start: ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
children.delete(name);
|
||||
if (!stopping) {
|
||||
if (code !== 0) {
|
||||
exitCode = code ?? 1;
|
||||
console.error(`[${name}] exited with ${signal ?? code}`);
|
||||
}
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function killChild(child, signal) {
|
||||
if (!child.pid || child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (child.detached) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the liveness check and the signal.
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll(signal) {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopping = true;
|
||||
for (const child of children.values()) {
|
||||
killChild(child, signal);
|
||||
}
|
||||
|
||||
const forceKill = setTimeout(() => {
|
||||
for (const child of children.values()) {
|
||||
killChild(child, "SIGKILL");
|
||||
}
|
||||
}, 2500);
|
||||
forceKill.unref();
|
||||
|
||||
const finish = setInterval(() => {
|
||||
if (children.size === 0) {
|
||||
clearInterval(finish);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(port, host)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${host}:${port}`);
|
||||
}
|
||||
|
||||
function canConnect(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ port, host });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => stopAll("SIGTERM"));
|
||||
process.on("SIGTERM", () => stopAll("SIGTERM"));
|
||||
|
||||
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
|
||||
cwd: appDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
BROWSER: "none",
|
||||
APP_VARIANT: "development",
|
||||
PASEO_WEB_PLATFORM: "electron",
|
||||
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(expoPort);
|
||||
} catch (error) {
|
||||
console.error(`[dev] ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
|
||||
if (!stopping) {
|
||||
spawnChild("electron", electron, [desktopDir], {
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
EXPO_DEV_URL: expoDevUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -49,12 +49,60 @@ $env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$R
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||
# works while the installed app is open. Without this the dev build loses the
|
||||
# Electron single-instance lock to the installed app and quits, and ends up
|
||||
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||
# its daemon config seeded below, so we never rewrite a production config.
|
||||
$DevStateDir = "$DesktopDir\.dev"
|
||||
if (-not $env:PASEO_HOME) {
|
||||
$env:PASEO_HOME = "$DevStateDir\paseo-home"
|
||||
$PaseoHomeManaged = $true
|
||||
} else {
|
||||
$PaseoHomeManaged = $false
|
||||
}
|
||||
if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" }
|
||||
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null
|
||||
|
||||
$DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" }
|
||||
if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" }
|
||||
|
||||
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||
# daemon is already running by reading `daemon.listen` from this config.json
|
||||
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||
# this it reads the default 6767, finds a production daemon there, and connects
|
||||
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if ($PaseoHomeManaged) {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$($env:PASEO_HOME)/config.json" $DevDaemonPort
|
||||
} else {
|
||||
Write-Host " (custom PASEO_HOME - leaving its config.json untouched)"
|
||||
}
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Desktop Dev (Windows)
|
||||
======================================================
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
||||
Daemon: $($env:PASEO_LISTEN) (isolated)
|
||||
PASEO_HOME: $($env:PASEO_HOME)
|
||||
userData: $($env:PASEO_ELECTRON_USER_DATA_DIR)
|
||||
======================================================
|
||||
"@
|
||||
|
||||
|
||||
@@ -3,36 +3,41 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
|
||||
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
source "$ROOT_DIR/scripts/dev-home.sh"
|
||||
|
||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||
# localhost origin across restarts. Fall back only when earlier ports are busy.
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085)
|
||||
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
|
||||
configure_dev_paseo_home
|
||||
|
||||
DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}"
|
||||
mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
if [ -z "${EXPO_PORT:-}" ]; then
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8082 8083 8084 8085 8086 8087 8088 8089)
|
||||
fi
|
||||
export EXPO_PORT
|
||||
export EXPO_DEV_URL="http://localhost:${EXPO_PORT}"
|
||||
|
||||
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
|
||||
export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
|
||||
|
||||
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
|
||||
export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT"
|
||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||
|
||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||
# the daemon websocket. Safe here because this script is development-only
|
||||
# and the daemon still binds to localhost.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
npm run build:main
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Metro: ${EXPO_DEV_URL}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Daemon: ${PASEO_LISTEN}"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
--kill-others \
|
||||
--names "metro,electron" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
"cd '$APP_DIR' && PASEO_WEB_PLATFORM=electron npx expo start --port $EXPO_PORT" \
|
||||
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
|
||||
exec node "$SCRIPT_DIR/dev-runner.mjs"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
@@ -21,7 +23,7 @@ vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
isPackaged: false,
|
||||
isPackaged: true,
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
|
||||
@@ -32,7 +34,7 @@ vi.mock("electron-log/main", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
|
||||
resolvePaseoHome: vi.fn(() => mocks.paseoHome),
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
@@ -72,8 +74,6 @@ function desktopSettingsWithManagement(enabled: boolean) {
|
||||
}
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
pid: number;
|
||||
spawnfile: string;
|
||||
spawnargs: string[];
|
||||
@@ -82,8 +82,6 @@ type MockChildProcess = EventEmitter & {
|
||||
|
||||
function createMockChildProcess(): MockChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 1234;
|
||||
child.spawnfile = "node";
|
||||
child.spawnargs = ["node", "daemon.js"];
|
||||
@@ -91,10 +89,8 @@ function createMockChildProcess(): MockChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
function scheduleFailedStartupOutput(child: MockChildProcess): void {
|
||||
function scheduleFailedStartup(child: MockChildProcess): void {
|
||||
setImmediate(() => {
|
||||
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
|
||||
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
|
||||
child.emit("exit", 1, null);
|
||||
});
|
||||
}
|
||||
@@ -105,6 +101,11 @@ describe("daemon-manager commands", () => {
|
||||
mocks.runExternalCliJsonCommand.mockReset();
|
||||
mocks.runExternalCliTextCommand.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
rmSync(mocks.paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(mocks.paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("refuses start and restart while built-in daemon management is disabled", async () => {
|
||||
@@ -136,7 +137,7 @@ describe("daemon-manager commands", () => {
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
@@ -167,7 +168,7 @@ describe("daemon-manager commands", () => {
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
@@ -195,7 +196,50 @@ describe("daemon-manager commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a reachable daemon when the PID file is stale", async () => {
|
||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "",
|
||||
status: "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
@@ -203,7 +247,7 @@ describe("daemon-manager commands", () => {
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.2",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
@@ -214,16 +258,86 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
version: "1.2.2",
|
||||
desktopManaged: false,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds captured daemon startup output", async () => {
|
||||
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
});
|
||||
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "server-2",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
pid: 8888,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
]);
|
||||
expect(mocks.spawnProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => {
|
||||
mkdirSync(mocks.paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
`${mocks.paseoHome}/daemon.log`,
|
||||
["old log line", "recent daemon failure"].join("\n"),
|
||||
);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
@@ -231,7 +345,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
mocks.spawnProcess.mockImplementation(() => {
|
||||
const child = createMockChildProcess();
|
||||
scheduleFailedStartupOutput(child);
|
||||
scheduleFailedStartup(child);
|
||||
return child;
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
@@ -246,9 +360,15 @@ describe("daemon-manager commands", () => {
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = thrown?.message ?? "";
|
||||
expect(message).toContain("Daemon failed to start: exit code 1");
|
||||
expect(message).toContain("output truncated to the last 65536 chars");
|
||||
expect(message).toContain("stdout-tail");
|
||||
expect(message).toContain("stderr-tail");
|
||||
expect(message.length).toBeLessThan(150_000);
|
||||
expect(message).toContain(`Recent logs (${mocks.paseoHome}/daemon.log):`);
|
||||
expect(message).toContain("recent daemon failure");
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledWith(
|
||||
"node",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
|
||||
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
|
||||
qr: string | null;
|
||||
}
|
||||
|
||||
interface StartupOutputCapture {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): AppReleaseChannel | undefined {
|
||||
@@ -151,30 +145,6 @@ function tailFile(filePath: string, lines = 50): string {
|
||||
}
|
||||
}
|
||||
|
||||
function createStartupOutputCapture(): StartupOutputCapture {
|
||||
return { text: "", truncated: false };
|
||||
}
|
||||
|
||||
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
|
||||
const nextText = capture.text + chunk.toString();
|
||||
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
|
||||
return { text: nextText, truncated: capture.truncated };
|
||||
}
|
||||
|
||||
return {
|
||||
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatStartupOutput(capture: StartupOutputCapture): string {
|
||||
if (!capture.truncated) {
|
||||
return capture.text;
|
||||
}
|
||||
|
||||
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
|
||||
}
|
||||
|
||||
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
|
||||
log.info("[desktop daemon]", message, {
|
||||
pid: process.pid,
|
||||
@@ -231,6 +201,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const desktopManaged = payload.desktopManaged === true;
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
@@ -248,7 +219,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
|
||||
desktopManaged,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -287,19 +258,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStartupFailureError(
|
||||
result: { code: number | null; signal: string | null; error?: Error },
|
||||
stdout: StartupOutputCapture,
|
||||
stderr: StartupOutputCapture,
|
||||
): Error {
|
||||
function buildStartupFailureError(result: {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
error?: Error;
|
||||
}): Error {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||
const parts = [`Daemon failed to start: ${reason}`];
|
||||
const formattedStderr = formatStartupOutput(stderr).trim();
|
||||
const formattedStdout = formatStartupOutput(stdout).trim();
|
||||
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
|
||||
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
|
||||
const logs = tailFile(logFilePath(), 15);
|
||||
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||
return new Error(parts.join("\n\n"));
|
||||
@@ -377,16 +344,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
envMode: "internal",
|
||||
env: invocation.env,
|
||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = createStartupOutputCapture();
|
||||
let stderr = createStartupOutputCapture();
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
stdout = appendStartupOutput(stdout, data);
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
stderr = appendStartupOutput(stderr, data);
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||
@@ -424,8 +382,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||
childPid: child.pid ?? null,
|
||||
exitedEarly: result.exitedEarly,
|
||||
stdout: formatStartupOutput(stdout).slice(0, 2000),
|
||||
stderr: formatStartupOutput(stderr).slice(0, 2000),
|
||||
...(result.exitedEarly
|
||||
? {
|
||||
exitCode: result.code,
|
||||
@@ -436,7 +392,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
});
|
||||
|
||||
if (result.exitedEarly) {
|
||||
throw buildStartupFailureError(result, stdout, stderr);
|
||||
throw buildStartupFailureError(result);
|
||||
}
|
||||
|
||||
return pollForRunningDaemon();
|
||||
@@ -444,7 +400,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const status = await resolveDesktopDaemonStatus();
|
||||
if (status.status !== "running" || !status.pid) return status;
|
||||
if (status.status !== "running") return status;
|
||||
|
||||
await stopDesktopDaemonViaCli();
|
||||
return await resolveDesktopDaemonStatus();
|
||||
|
||||
+47
@@ -1,8 +1,34 @@
|
||||
import { webContents as allWebContents, type WebContents } from "electron";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
handleBrowserWindowOpenRequest,
|
||||
isAllowedBrowserWebviewUrl,
|
||||
} from "./window-open.js";
|
||||
|
||||
export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest };
|
||||
|
||||
const browserIdsByWebContentsId = new Map<number, string>();
|
||||
let workspaceActiveBrowserId: string | null = null;
|
||||
|
||||
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
||||
const prefix = "persist:paseo-browser-";
|
||||
if (!partition?.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const browserId = partition.slice(prefix.length).trim();
|
||||
return browserId.length > 0 ? browserId : null;
|
||||
}
|
||||
|
||||
export function readBrowserIdFromWebviewAttach(input: {
|
||||
src?: string;
|
||||
partition?: string;
|
||||
}): string | null {
|
||||
if (!isAllowedBrowserWebviewUrl(input.src)) {
|
||||
return null;
|
||||
}
|
||||
return getBrowserIdFromWebviewPartition(input.partition);
|
||||
}
|
||||
|
||||
export function listRegisteredPaseoBrowserIds(): string[] {
|
||||
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
|
||||
}
|
||||
@@ -45,3 +71,24 @@ export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null
|
||||
}
|
||||
return getPaseoBrowserWebContents(workspaceActiveBrowserId);
|
||||
}
|
||||
|
||||
function preventUnsafeBrowserWebviewNavigation(
|
||||
event: { preventDefault: () => void },
|
||||
url: string | undefined,
|
||||
): void {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrowserWebviewNavigationGuards(contents: WebContents): void {
|
||||
contents.on("will-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-frame-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-redirect", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { handleBrowserWindowOpenRequest } from ".";
|
||||
|
||||
describe("browser webview window-open requests", () => {
|
||||
it("denies Electron window creation and requests a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).toHaveBeenCalledWith({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies unsupported window-open requests before asking for a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
url: "file:///etc/passwd",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export const BROWSER_NEW_TAB_REQUEST_EVENT = "paseo:event:browser-new-tab-request";
|
||||
|
||||
export interface BrowserNewTabRequestPayload {
|
||||
sourceBrowserId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleBrowserWindowOpenRequest(input: {
|
||||
url: string;
|
||||
sourceBrowserId: string | null;
|
||||
requestNewTab: (payload: BrowserNewTabRequestPayload) => void;
|
||||
}): { action: "deny" } {
|
||||
if (!isAllowedBrowserWebviewUrl(input.url) || !input.sourceBrowserId) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
input.requestNewTab({
|
||||
sourceBrowserId: input.sourceBrowserId,
|
||||
url: input.url,
|
||||
});
|
||||
return { action: "deny" };
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { app, Menu, BrowserWindow, ipcMain } from "electron";
|
||||
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews.js";
|
||||
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
|
||||
|
||||
interface ShowContextMenuInput {
|
||||
kind?: "terminal";
|
||||
hasSelection?: boolean;
|
||||
}
|
||||
|
||||
interface ApplicationMenuOptions {
|
||||
onNewWindow: () => void;
|
||||
}
|
||||
|
||||
function withBrowserWindow(
|
||||
callback: (win: BrowserWindow) => void,
|
||||
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
|
||||
@@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac
|
||||
win.webContents.reload();
|
||||
}
|
||||
|
||||
export function setupApplicationMenu(): void {
|
||||
function buildApplicationMenuTemplate(
|
||||
options: ApplicationMenuOptions,
|
||||
): Electron.MenuItemConstructorOptions[] {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
@@ -63,6 +69,18 @@ export function setupApplicationMenu(): void {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "CmdOrCtrl+Shift+N",
|
||||
click: () => {
|
||||
options.onNewWindow();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
@@ -130,8 +148,10 @@ export function setupApplicationMenu(): void {
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
export function setupApplicationMenu(options: ApplicationMenuOptions): void {
|
||||
const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options));
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
|
||||
|
||||
@@ -33,9 +33,10 @@ export const PASEO_SKILL_NAMES = [
|
||||
"paseo-advisor",
|
||||
"paseo-chat",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
// Keep removed bundle names here so auto-update deletes stale installed copies.
|
||||
"paseo-epic",
|
||||
"paseo-orchestrate",
|
||||
"paseo-orchestrator",
|
||||
] as const;
|
||||
|
||||
@@ -83,8 +83,8 @@ describe("syncSkills", () => {
|
||||
});
|
||||
|
||||
it("installs new bundled skills, including references/, when not present on disk", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo-epic", {
|
||||
"SKILL.md": "epic content",
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo-committee", {
|
||||
"SKILL.md": "committee content",
|
||||
"references/roles.md": "roles content",
|
||||
});
|
||||
|
||||
@@ -93,28 +93,30 @@ describe("syncSkills", () => {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo-epic"],
|
||||
skillNames: ["paseo-committee"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo-epic", "SKILL.md"), "utf-8")).toBe(
|
||||
"epic content",
|
||||
);
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.agentsDir, "paseo-committee", "SKILL.md"), "utf-8"),
|
||||
).toBe("committee content");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.agentsDir, "paseo-epic", "references", "roles.md"),
|
||||
path.join(sandbox.agentsDir, "paseo-committee", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo-epic", "references", "roles.md"),
|
||||
path.join(sandbox.codexDir, "paseo-committee", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-committee");
|
||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe(
|
||||
"committee content",
|
||||
);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
|
||||
+105
-91
@@ -46,13 +46,18 @@ import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
getPaseoBrowserIdForWebContents,
|
||||
getPaseoBrowserWebContents,
|
||||
handleBrowserWindowOpenRequest,
|
||||
listRegisteredPaseoBrowserIds,
|
||||
readBrowserIdFromWebviewAttach,
|
||||
registerBrowserWebviewNavigationGuards,
|
||||
registerPaseoBrowserWebContents,
|
||||
setWorkspaceActivePaseoBrowserId,
|
||||
} from "./features/browser-webviews.js";
|
||||
} from "./features/browser-webviews/index.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||
import {
|
||||
@@ -72,29 +77,6 @@ const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
|
||||
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
|
||||
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
|
||||
|
||||
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function preventUnsafeBrowserWebviewNavigation(
|
||||
event: Electron.Event,
|
||||
url: string | undefined,
|
||||
): void {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
||||
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
||||
|
||||
@@ -127,15 +109,6 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
|
||||
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
|
||||
app.setName(APP_NAME);
|
||||
|
||||
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
||||
const prefix = "persist:paseo-browser-";
|
||||
if (!partition?.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const browserId = partition.slice(prefix.length).trim();
|
||||
return browserId.length > 0 ? browserId : null;
|
||||
}
|
||||
|
||||
const pendingBrowserWebviewIds: string[] = [];
|
||||
|
||||
function isBrowserRefreshInput(input: Electron.Input): boolean {
|
||||
@@ -257,6 +230,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||
isDefaultApp: process.defaultApp,
|
||||
});
|
||||
|
||||
// Each window pulls its own pending open-project path on mount, keyed by
|
||||
// webContents id, so deep-linked windows (second-instance launches, the
|
||||
// in-app "Open in new window" action) land on the right project without
|
||||
// racing a global.
|
||||
const pendingOpenProjectStore = new PendingOpenProjectStore();
|
||||
|
||||
if (PASEO_DEBUG) {
|
||||
log.info("[open-project] argv:", process.argv);
|
||||
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
||||
@@ -265,10 +244,13 @@ if (PASEO_DEBUG) {
|
||||
|
||||
// The renderer pulls the pending path on mount via IPC — this avoids
|
||||
// a race where the push event arrives before React registers its listener.
|
||||
ipcMain.handle("paseo:get-pending-open-project", () => {
|
||||
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
|
||||
const result = pendingOpenProjectPath;
|
||||
pendingOpenProjectPath = null;
|
||||
ipcMain.handle("paseo:get-pending-open-project", (event) => {
|
||||
const webContentsId = event.sender.id;
|
||||
const result = pendingOpenProjectStore.take(webContentsId);
|
||||
log.info("[open-project] renderer requested pending path:", {
|
||||
webContentsId,
|
||||
pendingPath: result,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -326,7 +308,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
|
||||
});
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,12 +380,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||
return [primary, ...others].map((display) => display.workArea);
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
async function createWindow(
|
||||
options: {
|
||||
pendingOpenProjectPath?: string | null;
|
||||
restoreWindowState?: boolean;
|
||||
} = {},
|
||||
): Promise<BrowserWindow> {
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
||||
const savedWindowState = await windowStateStore.load();
|
||||
// Only the first window of a session restores and persists saved geometry.
|
||||
// Additional windows (⌘N, second-instance, "Open in new window") open at the
|
||||
// default size and let the OS cascade them, so they neither stack on top of
|
||||
// the restored window nor fight over the single window-state store.
|
||||
const restoreWindowState = options.restoreWindowState ?? false;
|
||||
const windowStateStore = restoreWindowState
|
||||
? createWindowStateStore({ userDataPath: app.getPath("userData") })
|
||||
: null;
|
||||
const savedWindowState = windowStateStore ? await windowStateStore.load() : null;
|
||||
const restoredWindowState = savedWindowState
|
||||
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
||||
: null;
|
||||
@@ -424,6 +421,12 @@ async function createMainWindow(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const webContentsId = mainWindow.webContents.id;
|
||||
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
|
||||
mainWindow.on("closed", () => {
|
||||
pendingOpenProjectStore.delete(webContentsId);
|
||||
});
|
||||
|
||||
if (devWorktreeName) {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
@@ -434,15 +437,13 @@ async function createMainWindow(): Promise<void> {
|
||||
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
if (windowStateStore) {
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
}
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
if (!isAllowedBrowserWebviewUrl(params.src)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const browserId = getBrowserIdFromWebviewPartition(params.partition);
|
||||
const browserId = readBrowserIdFromWebviewAttach(params);
|
||||
if (!browserId) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -502,25 +503,19 @@ async function createMainWindow(): Promise<void> {
|
||||
});
|
||||
}
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
contents.loadURL(url).catch(() => undefined);
|
||||
return { action: "deny" };
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) =>
|
||||
handleBrowserWindowOpenRequest({
|
||||
url,
|
||||
sourceBrowserId: getPaseoBrowserIdForWebContents(contents),
|
||||
requestNewTab: (payload) => {
|
||||
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload);
|
||||
},
|
||||
}),
|
||||
);
|
||||
contents.on("context-menu", (_contextMenuEvent, params) => {
|
||||
showBrowserWebviewContextMenu(mainWindow, contents, params);
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-frame-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-redirect", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
registerBrowserWebviewNavigationGuards(contents);
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
@@ -531,31 +526,27 @@ async function createMainWindow(): Promise<void> {
|
||||
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
||||
await loadReactDevTools();
|
||||
await mainWindow.loadURL(DEV_SERVER_URL);
|
||||
return;
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
||||
}
|
||||
|
||||
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
||||
const send = () => {
|
||||
log.info("[open-project] sending event to renderer:", projectPath);
|
||||
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
||||
};
|
||||
|
||||
if (win.webContents.isLoadingMainFrame()) {
|
||||
log.info("[open-project] waiting for did-finish-load before sending event");
|
||||
win.webContents.once("did-finish-load", send);
|
||||
return;
|
||||
}
|
||||
|
||||
send();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resolves once bootstrap() has registered the custom protocol handler and IPC
|
||||
// handlers and created the first window. second-instance window creation waits
|
||||
// on this rather than app.whenReady(): in packaged mode createWindow loads
|
||||
// `paseo://app/`, which fails if the protocol handler isn't registered yet, and
|
||||
// a second instance can arrive mid-cold-start.
|
||||
let resolveBootstrapComplete: () => void;
|
||||
const bootstrapComplete = new Promise<void>((resolve) => {
|
||||
resolveBootstrapComplete = resolve;
|
||||
});
|
||||
|
||||
function setupSingleInstanceLock(): boolean {
|
||||
if (DISABLE_SINGLE_INSTANCE_LOCK) {
|
||||
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
|
||||
@@ -575,15 +566,14 @@ function setupSingleInstanceLock(): boolean {
|
||||
isDefaultApp: false,
|
||||
});
|
||||
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
win.show();
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
if (openProjectPath) {
|
||||
sendOpenProjectEvent(win, openProjectPath);
|
||||
}
|
||||
}
|
||||
// Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new
|
||||
// window rather than focusing the existing one. Wait for bootstrap (not just
|
||||
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
|
||||
void bootstrapComplete
|
||||
.then(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
|
||||
.catch((error) => {
|
||||
log.error("[window] failed to create window from second-instance", error);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -689,7 +679,13 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
applyAppIcon();
|
||||
setupApplicationMenu();
|
||||
setupApplicationMenu({
|
||||
onNewWindow: () => {
|
||||
void createWindow().catch((error) => {
|
||||
log.error("[window] failed to create window from menu", error);
|
||||
});
|
||||
},
|
||||
});
|
||||
ensureNotificationCenterRegistration();
|
||||
if (await runDesktopSmokeIfRequested()) {
|
||||
return;
|
||||
@@ -701,11 +697,29 @@ async function bootstrap(): Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
registerEditorTargetHandlers();
|
||||
|
||||
await createMainWindow();
|
||||
// In-app "Open in new window": opens a window that lands on the given project
|
||||
// via the same open-project flow as a CLI launch (no move, no ownership).
|
||||
ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => {
|
||||
const pendingPath =
|
||||
options && typeof options === "object" && "pendingOpenProjectPath" in options
|
||||
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
|
||||
: null;
|
||||
await createWindow({
|
||||
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||
});
|
||||
});
|
||||
|
||||
// The first window of the session restores and persists saved geometry.
|
||||
await createWindow({ pendingOpenProjectPath, restoreWindowState: true });
|
||||
pendingOpenProjectPath = null;
|
||||
|
||||
// Protocol + IPC handlers and the first window now exist: release any
|
||||
// second-instance launches that arrived during cold start.
|
||||
resolveBootstrapComplete();
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createMainWindow();
|
||||
await createWindow({ restoreWindowState: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store";
|
||||
|
||||
describe("PendingOpenProjectStore", () => {
|
||||
it("stores pending paths per window and consumes them independently", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, "/tmp/project-a");
|
||||
store.set(202, "/tmp/project-b");
|
||||
|
||||
expect(store.take(101)).toBe("/tmp/project-a");
|
||||
expect(store.take(202)).toBe("/tmp/project-b");
|
||||
});
|
||||
|
||||
it("clears a pending path after it is consumed", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, "/tmp/project-a");
|
||||
|
||||
expect(store.take(101)).toBe("/tmp/project-a");
|
||||
expect(store.take(101)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores empty and whitespace-only paths", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, " ");
|
||||
|
||||
expect(store.take(101)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export class PendingOpenProjectStore {
|
||||
private readonly pendingPathByWebContentsId = new Map<number, string>();
|
||||
|
||||
set(webContentsId: number, projectPath: string | null | undefined): void {
|
||||
const normalizedPath = this.normalizeProjectPath(projectPath);
|
||||
if (!normalizedPath) {
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPathByWebContentsId.set(webContentsId, normalizedPath);
|
||||
}
|
||||
|
||||
take(webContentsId: number): string | null {
|
||||
const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null;
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
delete(webContentsId: number): void {
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
}
|
||||
|
||||
private normalizeProjectPath(projectPath: string | null | undefined): string | null {
|
||||
if (typeof projectPath !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedPath = projectPath.trim();
|
||||
return trimmedPath ? trimmedPath : null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
},
|
||||
window: {
|
||||
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
||||
ipcRenderer.invoke("paseo:window:openNew", options),
|
||||
getCurrentWindow: () => ({
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
|
||||
@@ -240,17 +240,19 @@ export function registerWindowManager(): void {
|
||||
}
|
||||
|
||||
export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
win.on("resize", () => {
|
||||
// A resize/fullscreen event can fire while the window is tearing down; sending
|
||||
// to a destroyed webContents throws. Guard so multi-window close doesn't surface
|
||||
// "Object has been destroyed" exceptions.
|
||||
const notifyResized = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
};
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
win.on("resize", notifyResized);
|
||||
win.on("enter-full-screen", notifyResized);
|
||||
win.on("leave-full-screen", notifyResized);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.90",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface McpStdioServerConfig {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* When true, all tools from this server are always included in the prompt
|
||||
* and never deferred behind tool search. Honored by the Claude provider.
|
||||
*/
|
||||
alwaysLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +28,11 @@ export interface McpHttpServerConfig {
|
||||
type: "http";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* When true, all tools from this server are always included in the prompt
|
||||
* and never deferred behind tool search. Honored by the Claude provider.
|
||||
*/
|
||||
alwaysLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +42,11 @@ export interface McpSseServerConfig {
|
||||
type: "sse";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* When true, all tools from this server are always included in the prompt
|
||||
* and never deferred behind tool search. Honored by the Claude provider.
|
||||
*/
|
||||
alwaysLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user