Merge branch 'main' into dev

Surgical merge of 74 commits from main into dev. Key features ported:

- Provider visibility gating (appVersion filtering for Pi/Copilot)
- Pi agent provider + Copilot re-enabled
- Provider-declared features system (Codex fast mode)
- Workspace dedup by worktree root (adapted to integer IDs)
- Bulk close archiving fix for stored agents
- Agent creation timeout increase to 60s
- Audio/voice crash fixes (external buffer copies)
- Multi-host setup fix
- Reload agent tab action

Dev architecture preserved: SQLite storage, service proxy,
workspace setup dialog, hover cards, removed launcher tabs.
This commit is contained in:
Mohamed Boudra
2026-04-05 12:59:07 +07:00
173 changed files with 8873 additions and 2316 deletions

View File

@@ -1,5 +1,63 @@
# Changelog
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added

View File

@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Debugging

206
docs/MOBILE_TESTING.md Normal file
View File

@@ -0,0 +1,206 @@
# Mobile Testing
## Maestro
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
Run a flow:
```bash
maestro test packages/app/maestro/my-flow.yaml
```
### Screenshots
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
```bash
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"
```
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
### Element targeting
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
```tsx
// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
```
```yaml
# Flow
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sidebar-sessions"
```
### Conditional steps
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
```yaml
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
```
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
### Don't use launchApp against a running dev app
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
### Swipe gestures
Use `start`/`end` with percentage coordinates for precise control:
```yaml
# Edge swipe from left to open sidebar
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
```
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
### Assertions
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
For async elements, use `extendedWaitUntil`:
```yaml
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
```
### Dev client handling
Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
This pattern also lets agents self-verify fixes without manual user testing.
### Pattern
1. Run baseline Maestro flow (confirm feature works)
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
3. Re-run Maestro flow (confirm feature still works)
4. Repeat N iterations to catch intermittent failures
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
```bash
bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test
```
Key elements of the script pattern:
```bash
set -euo pipefail
ITERATIONS="${1:-3}"
for i in $(seq 1 "$ITERATIONS"); do
# Toggle system state
xcrun simctl ui booted appearance light
# Wait for change to propagate
sleep 1
# Run Maestro flow and capture result
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
echo "PASS"
else
echo "FAIL"
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
fi
done
```
## Unistyles + Reanimated
### The crash
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
### The fix
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
```tsx
// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
overflow: "hidden",
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";
const staticStyles = RNStyleSheet.create({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
overflow: "hidden",
},
});
const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## iOS Simulator
```bash
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Dark/light mode
xcrun simctl ui booted appearance # check current
xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).

View File

@@ -122,8 +122,34 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-/e0XslInzzeodewkEeoCWw/FDYEVlmv79zsR2YS4kxc=";
npmDepsHash = "sha256-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

113
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.43",
"version": "0.1.47",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.43",
"version": "0.1.47",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -3088,14 +3088,6 @@
"node": ">=0.8.0"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
"license": "Apache-2.0",
"optional": true,
"peer": true
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -3534,6 +3526,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@@ -15967,6 +15966,24 @@
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -17399,9 +17416,9 @@
}
},
"node_modules/drizzle-orm": {
"version": "0.45.1",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
"version": "0.45.2",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
"integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
@@ -29434,6 +29451,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/pi-acp": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
"integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"zod": "^3.25.0"
},
"bin": {
"pi-acp": "dist/index.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/pi-acp/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -36294,16 +36345,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.43",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/expo-two-way-audio": "0.1.47",
"@getpaseo/highlight": "0.1.47",
"@getpaseo/server": "0.1.47",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -36420,11 +36471,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/relay": "0.1.47",
"@getpaseo/server": "0.1.47",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36465,11 +36516,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.43",
"version": "0.1.47",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/cli": "0.1.47",
"@getpaseo/server": "0.1.47",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -36503,7 +36554,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.43",
"version": "0.1.47",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -36704,7 +36755,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -36730,7 +36781,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -36746,14 +36797,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/relay": "0.1.43",
"@getpaseo/highlight": "0.1.47",
"@getpaseo/relay": "0.1.47",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -36771,6 +36822,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -36794,6 +36846,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.10",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
@@ -37155,7 +37208,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.43",
"version": "0.1.47",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.43",
"version": "0.1.47",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

2
packages/app/maestro/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Maestro takeScreenshot artifacts
*.png

View File

@@ -0,0 +1,31 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
# Small pause for close animation
- takeScreenshot: 00-sidebar-closed
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
- swipe:
direction: LEFT
duration: 300
- takeScreenshot: 02-sidebar-closed-again

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Verification loop for sidebar theme bug.
#
# Maestro can't toggle iOS appearance, so this script bridges the gap:
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
# still works. Runs N iterations to catch intermittent failures.
#
# Usage:
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
ITERATIONS="${1:-3}"
WAIT_SECS="${2:-1}"
mkdir -p "$OUT_DIR"
echo "=== Sidebar Theme Bug Verification ==="
echo "Output dir: $OUT_DIR"
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
FAILURES=0
for i in $(seq 1 "$ITERATIONS"); do
echo ""
echo "========== Iteration $i / $ITERATIONS =========="
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
echo "Current appearance: $CURRENT"
if [ "$CURRENT" = "dark" ]; then
xcrun simctl ui booted appearance light
echo "Switched to light mode"
else
xcrun simctl ui booted appearance dark
echo "Switched to dark mode"
fi
echo "Waiting ${WAIT_SECS}s..."
sleep "$WAIT_SECS"
ITER_DIR="$OUT_DIR/iter-$i"
mkdir -p "$ITER_DIR"
# Run maestro from the output dir so takeScreenshot artifacts land there
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
echo " -> PASS (iteration $i)"
else
echo " -> FAIL (iteration $i) — bug reproduced!"
FAILURES=$((FAILURES + 1))
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
fi
done
# Restore to dark mode
xcrun simctl ui booted appearance dark
echo ""
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
echo "Output: $OUT_DIR"

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.43",
"version": "0.1.47",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.43",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/expo-two-way-audio": "0.1.47",
"@getpaseo/highlight": "0.1.47",
"@getpaseo/server": "0.1.47",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -27,6 +27,7 @@ import {
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -68,6 +69,7 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
@@ -232,6 +234,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
@@ -242,28 +245,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
return;
}
})();
if (cancelled) {
return;
}
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
if (!cancelled) {
setPhase("online");
setError(null);
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
@@ -290,6 +318,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
@@ -396,14 +425,28 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
@@ -446,7 +489,7 @@ function MobileGestureWrapper({
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
@@ -461,8 +504,9 @@ function MobileGestureWrapper({
backdropOpacity,
animateToOpen,
animateToClose,
openAgentList,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
],
@@ -558,6 +602,84 @@ function OfferLinkListener({
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -692,6 +814,7 @@ export default function RootLayout() {
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
});
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();

View File

@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;

View File

@@ -9,7 +9,6 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -36,6 +35,7 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -59,9 +59,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -258,10 +256,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
@@ -275,44 +270,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -374,7 +331,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -761,90 +717,6 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
@@ -897,50 +769,8 @@ function PermissionRequestCard({
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
@@ -1013,6 +843,55 @@ function PermissionRequestCard({
)}
</Pressable>
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
</View>
);
}

View File

@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
const enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
});
if (shouldClose) {
animateToClose();
runOnJS(handleClose)("swipe-close-gesture");
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View>
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top },
explorerStaticStyles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
);
}
@@ -400,24 +410,28 @@ function SidebarContent({
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative",
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,

View File

@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface PiIconProps {
size?: number;
color?: string;
}
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
fillRule="evenodd"
/>
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
</Svg>
);
}

View File

@@ -10,7 +10,14 @@ import {
type RefObject,
type SetStateAction,
} from "react";
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
import {
View,
Pressable,
Text,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -370,14 +377,16 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent]);
}, [closeToAgent, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
@@ -452,7 +461,7 @@ function MobileSidebar({
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -471,7 +480,7 @@ function MobileSidebar({
backdropOpacity,
animateToClose,
animateToOpen,
handleClose,
handleCloseFromGesture,
],
);
@@ -498,13 +507,11 @@ function MobileSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -526,7 +533,7 @@ function MobileSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onWorkspacePress={() => closeToAgent()}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
@@ -688,7 +695,8 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
@@ -796,32 +804,37 @@ function DesktopSidebar({
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
},
desktopSidebar: {
position: "relative",
desktopSidebarBorder: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,

View File

@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -716,13 +717,6 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
@@ -1764,9 +1758,6 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1884,6 +1875,12 @@ export const ToolCall = memo(function ToolCall({
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
return (
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
);
}
return (
<ExpandableBadge
testID="tool-call-badge"

View File

@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
function createPlanMarkdownRules() {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}
export function PlanCard({
title = "Plan",
description,
text,
footer,
disableOuterSpacing = false,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
footer: {
gap: theme.spacing[2],
},
}));

View File

@@ -6,16 +6,17 @@ import { CodexIcon } from "@/components/icons/codex-icon";
import { GeminiIcon } from "@/components/icons/gemini-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
"claude-acp": ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
gemini: GeminiIcon as unknown as typeof Bot,
amp: AmpIcon as unknown as typeof Bot,
aider: AiderIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
pi: PiIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {

View File

@@ -84,6 +84,7 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -172,18 +173,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
@@ -261,6 +250,7 @@ export function SplitContainer({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -529,6 +519,7 @@ export function SplitContainer({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -650,6 +641,7 @@ function SplitNodeView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -684,6 +676,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -733,6 +726,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -781,6 +775,7 @@ function SplitPaneView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -891,6 +886,7 @@ function SplitPaneView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}

View File

@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -595,6 +596,7 @@ export function ContextMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -612,6 +614,7 @@ export function ContextMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
@@ -642,7 +645,7 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -704,6 +707,19 @@ export function ContextMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -27,6 +27,7 @@ import {
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -77,7 +77,6 @@ export function useWebElementScrollbar(
}
element.addEventListener("scroll", update, { passive: true });
element.addEventListener("input", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
@@ -90,7 +89,6 @@ export function useWebElementScrollbar(
return () => {
element.removeEventListener("scroll", update);
element.removeEventListener("input", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
setupHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginBottom: theme.spacing[6],
lineHeight: theme.fontSize.sm * 1.5,
},
setupLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
marginBottom: theme.spacing[6],
},
setupLinkText: {
color: theme.colors.accent,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -331,6 +351,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && Platform.OS !== "web" && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.
</Text>
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
</>
)}
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;

View File

@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>

View File

@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
],
);

View File

@@ -10,19 +10,19 @@ import {
Play,
Pause,
RotateCw,
Terminal,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliSymlinkInstructions,
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
@@ -30,7 +30,6 @@ import {
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
@@ -50,28 +49,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(version.version);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
@@ -218,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
updateSettings,
]);
const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isLoadingCliSymlinkInstructions) {
return;
}
setIsLoadingCliSymlinkInstructions(true);
setCliStatusMessage(null);
void getCliSymlinkInstructions()
.then((instructions) => {
setCliSymlinkInstructions(instructions);
setIsCliSymlinkModalOpen(true);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
})
.finally(() => {
setIsLoadingCliSymlinkInstructions(false);
});
}, [isLoadingCliSymlinkInstructions, showSection]);
const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliSymlinkInstructions?.commands) {
return;
}
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI symlink commands copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI symlink commands", error);
Alert.alert("Error", "Unable to copy CLI symlink commands.");
});
}, [cliSymlinkInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath;
if (!logPath) {
@@ -315,20 +278,47 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
setCliStatusOutput(await getCliDaemonStatus());
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
return;
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={styles.sectionLinkText}
style={styles.sectionLink}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
>
@@ -336,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
<View style={settingsStyles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -348,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
{showLifecycleControls ? (
<>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
@@ -379,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
@@ -401,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
</>
) : null}
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
@@ -444,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
@@ -458,6 +432,23 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
@@ -469,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : null}
<AdaptiveModalSheet
visible={isCliSymlinkModalOpen}
onClose={() => setIsCliSymlinkModalOpen(false)}
title="Add paseo to your shell"
testID="managed-daemon-cli-symlink-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Paseo does not add the command for you. Run the command below in your terminal.
</Text>
{cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliSymlinkInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands
</Button>
</View>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
@@ -518,12 +482,34 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isCliStatusModalOpen}
onClose={() => setIsCliStatusModalOpen(false)}
title="Daemon status"
testID="daemon-cli-status-dialog"
snapPoints={["60%", "85%"]}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliStatus}>
Copy
</Button>
</View>
</View>
</AdaptiveModalSheet>
</View>
);
}
@@ -581,7 +567,7 @@ function PairingOfferDialogContent(input: {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer</Text>
<Text style={settingsStyles.rowHint}>Loading pairing offer</Text>
</View>
);
}
@@ -589,7 +575,7 @@ function PairingOfferDialogContent(input: {
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
@@ -597,21 +583,21 @@ function PairingOfferDialogContent(input: {
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
@@ -632,37 +618,6 @@ function PairingOfferDialogContent(input: {
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -673,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: 2,
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
valueText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -686,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -0,0 +1,189 @@
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import {
shouldUseDesktopDaemon,
getCliInstallStatus,
installCli,
getSkillsInstallStatus,
installSkills,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
const loadStatus = useCallback(() => {
if (!showSection) return;
void getCliInstallStatus()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to load CLI status", error);
});
void getSkillsInstallStatus()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to load skills status", error);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
loadStatus();
return undefined;
}, [loadStatus, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
setIsInstallingCli(true);
void installCli()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to install CLI", error);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
setIsInstallingSkills(true);
void installSkills()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to install skills", error);
})
.finally(() => {
setIsInstallingSkills(false);
});
}, [isInstallingSkills]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
</View>
<Text style={settingsStyles.rowHint}>
Control and script agents from your terminal.
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "Install"}
</Button>
)}
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI.
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[0],
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
installedLabel: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -10,6 +10,8 @@ export type DesktopDaemonStatus = {
hostname: string | null;
pid: number | null;
home: string;
version: string | null;
desktopManaged: boolean;
error: string | null;
};
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
qr: string | null;
};
export type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
@@ -86,6 +82,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",
version: toStringOrNull(raw.version),
desktopManaged: raw.desktopManaged === true,
error: toStringOrNull(raw.error),
};
}
@@ -111,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
};
}
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) {
return null;
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime();
}
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
const instructions = parseCliSymlinkInstructionsInternal(raw);
if (!instructions) {
throw new Error("Unexpected CLI symlink instructions response.");
export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") {
throw new Error("Unexpected CLI daemon status response.");
}
return instructions;
}
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
return raw;
}
export type LocalTransportEventUnlisten = () => void;
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
export interface InstallStatus {
installed: boolean;
}
function parseInstallStatus(raw: unknown): InstallStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected install status response.");
}
return { installed: raw.installed === true };
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
}
export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
}

View File

@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;

View File

@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}

View File

@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
draftConfig?.modeId ?? null,
draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null,
draftConfig?.featureValues ?? null,
] as const;
}

View File

@@ -9,7 +9,6 @@ import {
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
@@ -74,7 +73,6 @@ function createBaseMemory(
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
it("uses silent catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
expect(sync.ui).toBe("silent");
});
it("keeps sync errors non-blocking once the screen was ready", () => {
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(ready);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(errorReady);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expectSyncErrorSync(ready);
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("closed");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.ui).toBe("silent");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});

View File

@@ -40,12 +40,9 @@ function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInpu
);
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: AgentScreenAgent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
@@ -55,17 +52,8 @@ export type AgentScreenReadySyncState =
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
| { status: "sync_error" };
export type AgentScreenViewState =
| {
@@ -99,7 +87,6 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
@@ -181,45 +168,23 @@ export function deriveAgentScreenViewState({
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
sync = { status: "sync_error" };
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
let ui: "overlay" | "toast" | "silent";
let ui: "overlay" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
ui = "silent";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
sync = { status: "catching_up", ui };
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
@@ -246,7 +211,6 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
@@ -255,7 +219,6 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}

View File

@@ -1,70 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
import { ActivityIndicator } from "react-native";
import type { ToastShowOptions } from "@/components/toast-host";
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
interface UseDelayedHistoryRefreshToastParams {
isCatchingUp: boolean;
indicatorColor: string;
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
}
export function useDelayedHistoryRefreshToast({
isCatchingUp,
indicatorColor,
showToast,
}: UseDelayedHistoryRefreshToastParams): void {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasCatchingUpRef = useRef(false);
const isCatchingUpRef = useRef(false);
const showToastRef = useRef(showToast);
const indicatorColorRef = useRef(indicatorColor);
useEffect(() => {
showToastRef.current = showToast;
}, [showToast]);
useEffect(() => {
indicatorColorRef.current = indicatorColor;
}, [indicatorColor]);
useEffect(() => {
isCatchingUpRef.current = isCatchingUp;
const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp;
const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp;
if (enteredCatchUp) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (!isCatchingUpRef.current) {
return;
}
showToastRef.current("Refreshing", {
icon: <ActivityIndicator size="small" color={indicatorColorRef.current} />,
durationMs: HISTORY_REFRESH_TOAST_DURATION_MS,
testID: "agent-history-refresh-toast",
});
}, HISTORY_REFRESH_TOAST_DELAY_MS);
} else if (exitedCatchUp && timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
wasCatchingUpRef.current = isCatchingUp;
}, [isCatchingUp]);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, []);
}

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
}) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const normalizedCwd = cwd?.trim() || "";
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
return null;
}
return {
provider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
provider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [featureValues, featuresQuery.data]);
useEffect(() => {
const next = pruneFeatureValues(featureValues, features);
if (next !== featureValues) {
setFeatureValues(next);
}
}, [featureValues, features]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
};
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
}
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useExplorerSidebarAnimation();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
onOpen();
}, [onOpen, gestureAnimatingRef]);
return useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
animateToOpen();
runOnJS(onOpen)();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
animateToOpen,
animateToClose,
isGesturing,
onOpen,
openGestureRef,
handleGestureOpen,
touchStartX,
touchStartY,
],

View File

@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveKeyboardFocusScope } from "./focus-scope";
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
tagName: string;
isContentEditable = false;
private selectors: Set<string>;
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
super();
this.tagName = (input?.tagName ?? "div").toUpperCase();
this.selectors = new Set(input?.selectors ?? []);
if (input?.isContentEditable) {
this.isContentEditable = true;
}
}
closest(selector: string): FakeElement | null {
if (this.selectors.has(selector)) {
return this;
}
return this.parentElement?.closest(selector) ?? null;
}
}
describe("resolveKeyboardFocusScope", () => {
const globalRef = globalThis as {
Element?: unknown;
Node?: unknown;
document?: { activeElement?: unknown };
};
const originalElement = globalRef.Element;
const originalNode = globalRef.Node;
const originalDocument = globalRef.document;
beforeEach(() => {
globalRef.Element = FakeElement;
globalRef.Node = FakeNode;
globalRef.document = { activeElement: null };
});
afterEach(() => {
globalRef.Element = originalElement;
globalRef.Node = originalNode;
globalRef.document = originalDocument;
});
it("resolves terminal scope from the direct keyboard event target", () => {
const target = new FakeElement({ selectors: [".xterm"] });
const scope = resolveKeyboardFocusScope({
target: target as unknown as EventTarget,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("falls back to activeElement when target is not an Element", () => {
const activeElement = new FakeElement({ selectors: [".xterm"] });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("detects editable scope from activeElement fallback", () => {
const activeElement = new FakeElement({ tagName: "input" });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("editable");
});
});

View File

@@ -1,37 +1,77 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
function isElement(value: unknown): value is Element {
return typeof Element !== "undefined" && value instanceof Element;
}
function getFocusCandidateElements(target: EventTarget | null): Element[] {
const candidates: Element[] = [];
const pushUnique = (element: Element | null) => {
if (!element || candidates.includes(element)) {
return;
}
candidates.push(element);
};
if (isElement(target)) {
pushUnique(target);
}
if (typeof Node !== "undefined" && target instanceof Node) {
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
}
if (typeof document !== "undefined" && isElement(document.activeElement)) {
pushUnique(document.activeElement);
}
return candidates;
}
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
const candidates = getFocusCandidateElements(target);
if (candidates.length === 0) {
return commandCenterOpen ? "command-center" : "other";
}
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
if (
candidates.some((element) =>
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
)
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
candidates.some((element) =>
Boolean(
element.closest("[data-testid='command-center-panel']") ||
element.closest("[data-testid='command-center-input']"),
),
)
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
if (
candidates.some((element) => {
const editable = element as HTMLElement;
if (editable.isContentEditable) {
return true;
}
const tag = element.tagName.toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select";
})
) {
return commandCenterOpen ? "command-center" : "editable";
}

View File

@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},

View File

@@ -14,6 +14,7 @@ import {
type HostProfile,
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
suppressSendErrors: true,
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
@@ -1681,6 +1683,41 @@ export class HostRuntimeStore {
};
}
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
let unsubscribe: (() => void) | null = null;
const isAnyOnline = (): boolean => {
for (const host of this.hosts) {
const snapshot = this.getSnapshot(host.serverId);
if (snapshot?.connectionStatus === "online") return true;
}
return false;
};
const promise = new Promise<void>((resolve) => {
if (isAnyOnline()) {
resolve();
return;
}
unsubscribe = this.subscribeAll(() => {
if (isAnyOnline()) {
unsubscribe?.();
unsubscribe = null;
resolve();
}
});
});
return {
promise,
cancel: () => {
unsubscribe?.();
unsubscribe = null;
},
};
}
ensureConnectedAll(): void {
for (const controller of this.controllers.values()) {
controller.ensureConnected();

View File

@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({
},
content: {
position: "relative",
flexGrow: 1,
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
logo: {
marginBottom: theme.spacing[8],

View File

@@ -20,6 +20,7 @@ import {
Stethoscope,
Info,
Shield,
Puzzle,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -50,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
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";
@@ -69,6 +71,7 @@ type SettingsSectionId =
| "hosts"
| "appearance"
| "shortcuts"
| "integrations"
| "diagnostics"
| "about"
| "permissions"
@@ -85,17 +88,21 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
{ id: "permissions", label: "Permissions", icon: Shield },
];
if (context.isDesktopApp) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
sections.push(
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
);
return sections;
}
@@ -177,13 +184,10 @@ interface HostsSectionProps {
routeServerId: string;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
setPendingEditReopenServerId: (id: string | null) => void;
setIsAddHostMethodVisible: (visible: boolean) => void;
isAddHostMethodVisible: boolean;
isDirectHostVisible: boolean;
isPasteLinkVisible: boolean;
addConnectionTargetServerId: string | null;
closeAddConnectionFlow: () => void;
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
@@ -203,7 +207,6 @@ interface HostsSectionProps {
handleSaveEditDaemon: (label: string) => Promise<void>;
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
handleRemoveDaemon: (profile: HostProfile) => void;
handleAddConnectionFromModal: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -243,8 +246,6 @@ function HostsSection(props: HostsSectionProps) {
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => {
props.setAddConnectionTargetServerId(null);
props.setPendingEditReopenServerId(null);
props.setIsAddHostMethodVisible(true);
}}
>
@@ -264,22 +265,17 @@ function HostsSection(props: HostsSectionProps) {
props.setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = props.addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = props.routeServerId || targetServerId || undefined;
const sourceServerId = props.routeServerId || undefined;
props.closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
params: { source: "settings", sourceServerId },
});
}}
/>
<AddHostModal
visible={props.isDirectHostVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -291,7 +287,6 @@ function HostsSection(props: HostsSectionProps) {
<PairLinkModal
visible={props.isPasteLinkVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -371,7 +366,6 @@ function HostsSection(props: HostsSectionProps) {
onSave={(label) => void props.handleSaveEditDaemon(label)}
onRemoveConnection={props.handleRemoveConnection}
onRemoveHost={props.handleRemoveDaemon}
onAddConnection={props.handleAddConnectionFromModal}
restartConfirmationMessage={props.restartConfirmationMessage}
waitForCondition={props.waitForCondition}
isScreenMountedRef={props.isMountedRef}
@@ -520,6 +514,8 @@ function SettingsSectionContent({
return <DiagnosticsSection {...diagnosticsProps} />;
case "about":
return <AboutSection {...aboutProps} />;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "daemon":
@@ -570,31 +566,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
{sections.map((section) => {
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
const showSeparator =
section.id === "integrations" || section.id === "diagnostics";
return (
<Pressable
key={section.id}
style={[
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
<View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
<Pressable
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
numberOfLines={1}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
{section.label}
</Text>
</Pressable>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
]}
numberOfLines={1}
>
{section.label}
</Text>
</Pressable>
</View>
);
})}
</View>
@@ -728,10 +728,6 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
null,
);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
@@ -810,7 +806,6 @@ export default function SettingsScreen() {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
@@ -829,24 +824,6 @@ export default function SettingsScreen() {
handleEditDaemon(profile);
}, [daemons, handleEditDaemon, params.editHost]);
useEffect(() => {
if (!pendingEditReopenServerId) return;
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
setPendingEditReopenServerId(null);
setAddConnectionTargetServerId(null);
if (profile) {
handleEditDaemon(profile);
}
}, [
daemons,
handleEditDaemon,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
pendingEditReopenServerId,
]);
const handleSaveEditDaemon = useCallback(
async (nextLabelRaw: string) => {
if (!editingServerId) return;
@@ -884,15 +861,6 @@ export default function SettingsScreen() {
setPendingRemoveHost(profile);
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
@@ -938,13 +906,10 @@ export default function SettingsScreen() {
routeServerId,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
setPendingEditReopenServerId,
setIsAddHostMethodVisible,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
addConnectionTargetServerId,
closeAddConnectionFlow,
goBackToAddConnectionMethods,
setIsDirectHostVisible,
@@ -964,7 +929,6 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
handleAddConnectionFromModal,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
@@ -1029,7 +993,6 @@ interface HostDetailModalProps {
onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -1047,7 +1010,6 @@ function HostDetailModal({
onSave,
onRemoveConnection,
onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
@@ -1278,15 +1240,6 @@ function HostDetailModal({
/>
);
})}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection}
>
+ Add connection
</Button>
</View>
</View>
) : null}
@@ -1893,6 +1846,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
sidebarSeparator: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
marginVertical: theme.spacing[2],
marginHorizontal: theme.spacing[3],
},
contentPane: {
flex: 1,
},

View File

@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
subsectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
it("uses desktop tab ordering labels for desktop menus", () => {
const onCopyResumeCommand = vi.fn();
const onCopyAgentId = vi.fn();
const onReloadAgent = vi.fn();
const onCloseTab = vi.fn();
const onCloseTabsBefore = vi.fn();
const onCloseTabsAfter = vi.fn();
@@ -28,6 +29,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -37,6 +39,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close to the left",
"Close to the right",
"Close other tabs",
@@ -53,6 +56,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -62,6 +66,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close tabs above",
"Close tabs below",
"Close other tabs",
@@ -83,6 +88,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-draft_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -92,6 +98,34 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
});
it("adds reload tooltip copy for agent tabs", () => {
const entries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab: createAgentTab(),
index: 0,
tabCount: 1,
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
expect(entries).toContainEqual(
expect.objectContaining({
kind: "item",
key: "reload-agent",
tooltip: "Reload agent to update skills, MCPs or login status.",
}),
);
});
});

View File

@@ -8,8 +8,15 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
icon?:
| "copy"
| "rotate-cw"
| "arrow-left-to-line"
| "arrow-right-to-line"
| "copy-x"
| "x";
hint?: string;
tooltip?: string;
disabled?: boolean;
destructive?: boolean;
testID: string;
@@ -28,6 +35,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
menuTestIDBase: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
@@ -40,6 +48,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
tabCount: number;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
@@ -95,6 +104,7 @@ export function buildWorkspaceTabMenuEntries(
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -128,6 +138,17 @@ export function buildWorkspaceTabMenuEntries(
void onCopyAgentId(agentId);
},
});
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
},
});
entries.push({
kind: "separator",
key: "copy-separator",
@@ -195,6 +216,7 @@ export function buildWorkspaceDesktopTabActions(
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId,
onReloadAgent: input.onReloadAgent,
onCloseTab: input.onCloseTab,
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,

View File

@@ -10,6 +10,7 @@ import type {
AgentPermissionResponse,
AgentPermissionRequest,
AgentSessionConfig,
AgentFeature,
AgentProvider,
AgentMode,
AgentCapabilityFlags,
@@ -100,6 +101,7 @@ export interface Agent {
title: string | null;
cwd: string;
model: string | null;
features?: AgentFeature[];
thinkingOptionId?: string | null;
requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null;

View File

@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionHeaderTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
sectionHeaderLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionHeaderLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
card: {
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
}));

View File

@@ -286,6 +286,25 @@ export class TerminalEmulatorRuntime {
}
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void navigator.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste from clipboard into terminal
if (key === "v") {
event.preventDefault();
void navigator.clipboard.readText().then((text) => {
if (text) {
terminal.paste(text);
}
});
return false;
}
return true;
}

View File

@@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
features: snapshot.features,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,

View File

@@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => {
it("returns the main repo root for paseo-owned worktrees", () => {
const checkout = {
isPaseoOwnedWorktree: true,
worktreeRoot: "/repo/.paseo/worktrees/feature",
mainRepoRoot: "/repo/main",
} as CheckoutStatusPayload;
} as unknown as CheckoutStatusPayload;
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
"/repo/main",

View File

@@ -28,6 +28,7 @@ describe("project-placement", () => {
isGit: true as const,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: "/Users/test/repo",
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
},

View File

@@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},

View File

@@ -2,6 +2,7 @@ import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
@@ -53,6 +54,7 @@ export async function buildClientConfig(
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...(connection.type === "directSocket" || connection.type === "directPipe"

View File

@@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
);
case "plain_text":
return Boolean(detail.label || detail.text);
case "plan":
return detail.text.trim().length > 0;
case "unknown":
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
}

View File

@@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
worktree_setup: SquareTerminal,
sub_agent: Bot,
plain_text: Wrench,
plan: Brain,
unknown: Wrench,
};

View File

@@ -14,20 +14,6 @@ interface AudioEngineTraceOptions {
traceLabel?: string;
}
let nextAudioEngineInstanceId = 1;
interface BridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
volumeEvents: number;
volumeMax: number;
playbackEvents: number;
playbackInputBytes: number;
playbackResampledBytes: number;
playbackDurationMs: number;
}
function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
@@ -85,48 +71,6 @@ export function createAudioEngine(
_options?: AudioEngineTraceOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
const instanceId = nextAudioEngineInstanceId++;
const bridgeStats: BridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
volumeEvents: 0,
volumeMax: 0,
playbackEvents: 0,
playbackInputBytes: 0,
playbackResampledBytes: 0,
playbackDurationMs: 0,
};
const toHexPreview = (bytes: Uint8Array, count = 12): string =>
Array.from(bytes.slice(0, count))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ");
const maybeFlushBridgeStats = (reason: string): void => {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` +
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
`windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.volumeEvents = 0;
bridgeStats.volumeMax = 0;
bridgeStats.playbackEvents = 0;
bridgeStats.playbackInputBytes = 0;
bridgeStats.playbackResampledBytes = 0;
bridgeStats.playbackDurationMs = 0;
};
const refs: {
initialized: boolean;
@@ -140,8 +84,6 @@ export function createAudioEngine(
reject: (error: Error) => void;
settled: boolean;
} | null;
sawFirstMicChunk: boolean;
sawFirstVolumeEvent: boolean;
destroyed: boolean;
} = {
initialized: false,
@@ -151,8 +93,6 @@ export function createAudioEngine(
processingQueue: false,
playbackTimeout: null,
activePlayback: null,
sawFirstMicChunk: false,
sawFirstVolumeEvent: false,
destroyed: false,
};
@@ -163,15 +103,6 @@ export function createAudioEngine(
return;
}
const pcm = event.data as Uint8Array;
if (!refs.sawFirstMicChunk) {
refs.sawFirstMicChunk = true;
console.log(
`[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`,
);
}
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += pcm.byteLength;
maybeFlushBridgeStats("capture");
callbacks.onCaptureData(pcm);
},
);
@@ -182,26 +113,10 @@ export function createAudioEngine(
return;
}
const level = refs.muted ? 0 : event.data;
bridgeStats.volumeEvents += 1;
bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level);
if (!refs.sawFirstVolumeEvent) {
refs.sawFirstVolumeEvent = true;
console.log(
`[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`,
);
}
maybeFlushBridgeStats("volume");
callbacks.onVolumeLevel(level);
},
);
const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener(
"onOutputVolumeLevelData",
(event: any) => {
console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`);
},
);
async function ensureInitialized(): Promise<void> {
if (refs.initialized) {
return;
@@ -210,20 +125,13 @@ export function createAudioEngine(
if (!success) {
throw new Error("expo-two-way-audio: native initialize() returned false");
}
console.log(`[AudioEngine.native#${instanceId}] initialized successfully`);
refs.initialized = true;
}
async function ensureMicrophonePermission(): Promise<void> {
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
if (!permission?.granted) {
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
}
if (!permission?.granted) {
throw new Error(
@@ -253,17 +161,6 @@ export function createAudioEngine(
// Native AudioEngine expects 16kHz PCM16
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
const durationSec = pcm16k.length / 2 / 16000;
bridgeStats.playbackEvents += 1;
bridgeStats.playbackInputBytes += pcm.length;
bridgeStats.playbackResampledBytes += pcm16k.length;
bridgeStats.playbackDurationMs += durationSec * 1000;
console.log(
`[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` +
`resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` +
`pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`,
);
maybeFlushBridgeStats("play");
native.resumePlayback();
native.playPCMData(pcm16k);
@@ -334,26 +231,18 @@ export function createAudioEngine(
}
microphoneSubscription.remove();
volumeSubscription.remove();
outputVolumeSubscription.remove();
},
async startCapture() {
if (refs.captureActive) {
console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`);
return;
}
try {
console.log(`[AudioEngine.native#${instanceId}] startCapture begin`);
await ensureMicrophonePermission();
await ensureInitialized();
refs.sawFirstMicChunk = false;
refs.sawFirstVolumeEvent = false;
const isRecording = native.toggleRecording(true);
native.toggleRecording(true);
refs.captureActive = true;
console.log(
`[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`,
);
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
callbacks.onError?.(wrapped);
@@ -363,10 +252,7 @@ export function createAudioEngine(
async stopCapture() {
if (refs.captureActive) {
const isRecording = native.toggleRecording(false);
console.log(
`[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`,
);
native.toggleRecording(false);
}
refs.captureActive = false;
refs.muted = false;

View File

@@ -3,7 +3,6 @@ import type { AgentStreamEventPayload, SessionOutboundMessage } from "@server/sh
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
import {
THINKING_TONE_NATIVE_PCM_BASE64,
THINKING_TONE_NATIVE_PCM_DURATION_MS,
@@ -82,8 +81,6 @@ interface RuntimeState {
segmentDurationTimer: ReturnType<typeof setInterval> | null;
lastDisplayVolumePublishMs: number;
serverSpeechStartedAt: number | null;
lastNoServerSpeechLogMs: number;
localAboveThresholdActive: boolean;
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
@@ -120,19 +117,6 @@ interface CueState {
playing: boolean;
}
interface RealtimeBridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
uplinkEvents: number;
uplinkRawBytes: number;
uplinkBase64Chars: number;
outputEvents: number;
outputBytes: number;
outputGroups: number;
jsLagMaxMs: number;
}
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
@@ -210,8 +194,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
segmentDurationTimer: null,
lastDisplayVolumePublishMs: 0,
serverSpeechStartedAt: null,
lastNoServerSpeechLogMs: 0,
localAboveThresholdActive: false,
};
const playback: RuntimePlaybackState = {
groups: new Map(),
@@ -220,18 +202,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
processing: false,
generation: 0,
};
const bridgeStats: RealtimeBridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
uplinkEvents: 0,
uplinkRawBytes: 0,
uplinkBase64Chars: 0,
outputEvents: 0,
outputBytes: 0,
outputGroups: 0,
jsLagMaxMs: 0,
};
const cue: CueState = {
active: false,
token: 0,
@@ -246,41 +216,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return cuePcm16.buffer.slice(cuePcm16.byteOffset, cuePcm16.byteOffset + cuePcm16.byteLength);
},
};
let lagProbeLastMs = Date.now();
const lagProbe = setInterval(() => {
const now = Date.now();
const lagMs = Math.max(0, now - lagProbeLastMs - 100);
lagProbeLastMs = now;
if (lagMs > bridgeStats.jsLagMaxMs) {
bridgeStats.jsLagMaxMs = lagMs;
}
}, 100);
function flushBridgeStats(reason: string): void {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[VoiceRuntime#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` +
`output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` +
`jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.uplinkEvents = 0;
bridgeStats.uplinkRawBytes = 0;
bridgeStats.uplinkBase64Chars = 0;
bridgeStats.outputEvents = 0;
bridgeStats.outputBytes = 0;
bridgeStats.outputGroups = 0;
bridgeStats.jsLagMaxMs = 0;
}
function emit(): void {
for (const listener of listeners) {
listener();
@@ -354,17 +289,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
function resetPlaybackState(): void {
const hadGroups = playback.groups.size;
playback.generation += 1;
playback.groups.clear();
playback.orderedGroupIds = [];
playback.activeGroupId = null;
playback.processing = false;
if (hadGroups > 0) {
console.log(
`[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`,
);
}
}
function activateNextPlaybackGroup(): void {
@@ -394,15 +323,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
playback.processing = true;
const generation = playback.generation;
console.log(
`[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`,
);
try {
while (playback.activeGroupId) {
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -415,9 +338,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const nextChunk = group.chunks.get(group.nextChunkToPlay);
if (!nextChunk) {
if (group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex) {
console.log(
`[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`,
);
playback.groups.delete(group.groupId);
if (playback.orderedGroupIds[0] === group.groupId) {
playback.orderedGroupIds.shift();
@@ -432,9 +352,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
activateNextPlaybackGroup();
continue;
}
console.log(
`[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`,
);
return;
}
@@ -442,39 +359,21 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (group.shouldPlay && !group.started && group.isVoiceMode) {
group.started = true;
console.log(
`[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`,
);
api.onAssistantAudioStarted(serverId);
}
const playStart = Date.now();
try {
if (group.shouldPlay) {
await deps.engine.play(nextChunk.source);
console.log(
`[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`,
);
} else {
console.log(
`[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`,
);
}
} catch (error) {
if (generation !== playback.generation) {
console.log(`[VoiceRuntime] play error + generation changed, aborting`);
return;
}
console.error(
`[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`,
error,
);
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay}:`, error);
}
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -491,9 +390,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (generation === playback.generation) {
playback.processing = false;
}
console.log(
`[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`,
);
}
}
@@ -607,12 +503,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
const base64 = Buffer.from(chunk).toString("base64");
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += chunk.byteLength;
bridgeStats.uplinkEvents += 1;
bridgeStats.uplinkRawBytes += chunk.byteLength;
bridgeStats.uplinkBase64Chars += base64.length;
flushBridgeStats("uplink");
void activeSession.adapter.sendVoiceAudioChunk(base64, PCM_MIME_TYPE).catch((error) => {
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
@@ -625,8 +515,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
state.turnInProgress = false;
state.serverSpeechDetected = false;
state.lastDisplayVolumePublishMs = 0;
state.lastNoServerSpeechLogMs = 0;
state.localAboveThresholdActive = false;
uploader.reset();
resetCaptureTelemetry();
patchSnapshot({ ...INITIAL_SNAPSHOT });
@@ -747,11 +635,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
return;
}
if (bridgeStats.captureEvents === 0) {
console.log(
`[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
uploader.pushPcmChunk(chunk);
},
@@ -768,30 +651,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
if (isActive && !state.localAboveThresholdActive) {
state.localAboveThresholdActive = true;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
if (!isActive && state.localAboveThresholdActive) {
state.localAboveThresholdActive = false;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`,
);
}
if (
isActive &&
!state.serverSpeechDetected &&
nowMs - state.lastNoServerSpeechLogMs >= 1500
) {
state.lastNoServerSpeechLogMs = nowMs;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`,
);
}
patchTelemetry((prev) => ({
...prev,
isSpeaking: state.serverSpeechDetected,
@@ -806,33 +665,15 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
!state.snapshot.isVoiceMode ||
!payload.isVoiceMode
) {
console.log(
`[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`,
);
return;
}
const groupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const decoded = decodeAudioChunk(payload.audio);
bridgeStats.outputEvents += 1;
bridgeStats.outputBytes += decoded.byteLength;
bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1;
console.log(
`[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` +
`base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` +
`head=${Array.from(decoded.slice(0, 12))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ")}`,
);
flushBridgeStats("audio_output");
let group = playback.groups.get(groupId);
if (!group) {
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
console.log(
`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`,
);
group = {
groupId,
isVoiceMode: payload.isVoiceMode,
@@ -966,7 +807,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
async destroy() {
await this.stopVoice().catch(() => undefined);
clearInterval(lagProbe);
await deps.engine.destroy();
listeners.clear();
telemetryListeners.clear();
@@ -1057,9 +897,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
console.log(
`[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`,
);
state.serverSpeechDetected = isSpeaking;
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
if (isSpeaking) {

View File

@@ -1,2 +1,3 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=DEP0040
import '../dist/index.js'

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.43",
"version": "0.1.47",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/relay": "0.1.47",
"@getpaseo/server": "0.1.47",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -0,0 +1,188 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js";
const knownCommands = new Set(["ls", "run", "status"]);
describe("classifyInvocation", () => {
it("classifies no args as CLI mode", () => {
expect(
classifyInvocation({
argv: [],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: [] });
});
it("classifies flags as CLI mode", () => {
expect(
classifyInvocation({
argv: ["--version"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["--version"] });
});
it("classifies known commands as CLI mode", () => {
expect(
classifyInvocation({
argv: ["ls", "--json"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["ls", "--json"] });
});
it("classifies '.' as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-"));
expect(
classifyInvocation({
argv: ["."],
knownCommands,
cwd: projectDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies '..' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-"));
const childDir = path.join(parentDir, "child");
mkdirSync(childDir);
expect(
classifyInvocation({
argv: [".."],
knownCommands,
cwd: childDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: parentDir,
});
});
it("classifies './myproject' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["./myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an absolute path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-"));
expect(
classifyInvocation({
argv: [projectDir],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies a home-relative path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-"));
const relativeToHome = `~/${path.basename(projectDir)}`;
expect(
classifyInvocation({
argv: [relativeToHome],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an existing directory name as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("keeps known commands in CLI mode even when a matching directory exists", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-"));
mkdirSync(path.join(parentDir, "status"));
expect(
classifyInvocation({
argv: ["status"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "cli",
argv: ["status"],
});
});
it("classifies nonexistent args as CLI mode", () => {
expect(
classifyInvocation({
argv: ["nonexistent"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "cli",
argv: ["nonexistent"],
});
});
});
describe("path helpers", () => {
it("detects path-like prefixes", () => {
expect(isPathLikeArg(".")).toBe(true);
expect(isPathLikeArg("..")).toBe(true);
expect(isPathLikeArg("./project")).toBe(true);
expect(isPathLikeArg("../project")).toBe(true);
expect(isPathLikeArg("/tmp/project")).toBe(true);
expect(isPathLikeArg("~")).toBe(true);
expect(isPathLikeArg("~/project")).toBe(true);
expect(isPathLikeArg("C:\\project")).toBe(true);
expect(isPathLikeArg("status")).toBe(false);
});
it("detects existing directories relative to cwd", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-"));
const projectDir = path.join(parentDir, "project");
mkdirSync(projectDir);
expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true);
expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
export type CliInvocation =
| { kind: "cli"; argv: string[] }
| { kind: "open-project"; resolvedPath: string };
export function isPathLikeArg(arg: string): boolean {
return (
arg === "." ||
arg === ".." ||
arg.startsWith("./") ||
arg.startsWith("../") ||
arg.startsWith("/") ||
arg === "~" ||
arg.startsWith("~/") ||
/^[A-Za-z]:[\\/]/.test(arg)
);
}
export function expandUserPath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean {
const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg));
if (!existsSync(resolvedPath)) {
return false;
}
return statSync(resolvedPath).isDirectory();
}
export function classifyInvocation(input: {
argv: string[];
knownCommands: ReadonlySet<string>;
cwd: string;
}): CliInvocation {
const [firstArg] = input.argv;
if (!firstArg) {
return { kind: "cli", argv: input.argv };
}
if (firstArg.startsWith("-")) {
return { kind: "cli", argv: input.argv };
}
if (input.knownCommands.has(firstArg)) {
return { kind: "cli", argv: input.argv };
}
if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) {
return {
kind: "open-project",
resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)),
};
}
return { kind: "cli", argv: input.argv };
}

View File

@@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo {
hostname?: string;
uid?: number;
listen?: string;
desktopManaged?: boolean;
}
export interface LocalDaemonState {
@@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
: typeof parsed.sockPath === "string"
? parsed.sockPath
: undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
return null;

View File

@@ -34,6 +34,8 @@ interface DaemonStatus {
daemonNode: string;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
desktopManaged: boolean;
providers: ProviderBinaryStatus[];
note?: string;
}
@@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
];
if (status.runningAgents !== null && status.idleAgents !== null) {
@@ -229,6 +232,7 @@ export async function runStatusCommand(
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let daemonVersion: string | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -240,6 +244,7 @@ export async function runStatusCommand(
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
connectedDaemon = "reachable";
daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
@@ -308,6 +313,8 @@ export async function runStatusCommand(
daemonNode,
cliNode,
cliVersion,
daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
};

View File

@@ -0,0 +1,99 @@
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import path from "node:path";
function findDesktopApp(): string | null {
if (process.platform === "darwin") {
const candidates = [
"/Applications/Paseo.app",
path.join(homedir(), "Applications", "Paseo.app"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
if (process.platform === "linux") {
const candidates = [
"/usr/bin/Paseo",
"/opt/Paseo/Paseo",
path.join(homedir(), "Applications", "Paseo.AppImage"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
return null;
}
const candidate = path.join(localAppData, "Programs", "Paseo", "Paseo.exe");
return existsSync(candidate) ? candidate : null;
}
return null;
}
function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
const env = { ...process.env };
// The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned
// desktop process inherits the env directly, so we must strip it or the
// desktop app would start as a bare Node process instead of Electron.
delete env.ELECTRON_RUN_AS_NODE;
return env;
}
function spawnDetached(command: string, args: string[]): void {
spawn(command, args, {
detached: true,
stdio: "ignore",
env: cleanEnvForDesktopLaunch(),
}).unref();
}
export async function openDesktopWithProject(projectPath: string): Promise<void> {
try {
if (process.env.PASEO_DESKTOP_CLI === "1") {
throw new Error(
"Cannot open a desktop project while running in desktop CLI passthrough mode.",
);
}
const desktopApp = findDesktopApp();
if (!desktopApp) {
throw new Error(
"Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases",
);
}
if (process.platform === "darwin") {
// -n forces a new instance even if the app is already running.
// The new instance hits requestSingleInstanceLock(), fails, and relays
// the argv to the first instance via the second-instance event.
// -g keeps the terminal in the foreground (better CLI UX).
// Without -n, macOS just activates the existing window and drops --args.
spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]);
return;
}
spawnDetached(desktopApp, [projectPath]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
}

View File

@@ -1,7 +1,26 @@
import { createCli } from "./cli.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
const program = createCli();
if (process.argv.length <= 2) {
process.argv.push("onboard");
const knownCommands = new Set(program.commands.map((command) => command.name()));
const invocation = classifyInvocation({
argv: process.argv.slice(2),
knownCommands,
cwd: process.cwd(),
});
switch (invocation.kind) {
case "cli": {
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
if (invocation.argv.length === 0) {
argv.push("onboard");
}
program.parse(argv, { from: "node" });
break;
}
case "open-project":
await openDesktopWithProject(invocation.resolvedPath);
break;
}
program.parse();

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env npx zx
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
classifyInvocation,
isExistingDirectory,
isPathLikeArg,
} from "../src/classify.ts";
import { openDesktopWithProject } from "../src/commands/open.ts";
console.log("📋 Phase 32: Open Project CLI Tests\n");
console.log(" Testing path-like detection exports...");
assert.equal(isPathLikeArg("."), true);
assert.equal(isPathLikeArg("./app"), true);
assert.equal(isPathLikeArg("/tmp/app"), true);
assert.equal(isPathLikeArg("~/app"), true);
assert.equal(isPathLikeArg("run"), false);
assert.equal(isPathLikeArg("foo"), false);
console.log(" ✅ path-like detection matches the expected prefixes");
console.log(" Testing existing directory detection and command precedence...");
const existingProject = join(await mkdtemp(join(tmpdir(), "paseo-open-project-")), "project");
await mkdir(existingProject);
const originalCwd = process.cwd();
process.chdir(join(existingProject, ".."));
assert.equal(isExistingDirectory({ pathArg: "project", cwd: process.cwd() }), true);
assert.equal(
classifyInvocation({
argv: ["project"],
knownCommands: new Set(["run", "status"]),
cwd: process.cwd(),
}).kind,
"open-project",
);
assert.equal(
classifyInvocation({
argv: ["run"],
knownCommands: new Set(["run", "status"]),
cwd: process.cwd(),
}).kind,
"cli",
);
process.chdir(originalCwd);
console.log(" ✅ existing directories open as projects, but known commands still win");
console.log(" Testing desktop CLI passthrough guard...");
const originalWrite = process.stderr.write.bind(process.stderr);
const stderrChunks: string[] = [];
process.stderr.write = ((chunk: string | Uint8Array) => {
stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stderr.write;
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const previousDesktopCli = process.env.PASEO_DESKTOP_CLI;
process.env.PASEO_DESKTOP_CLI = "1";
await openDesktopWithProject(existingProject);
process.stderr.write = originalWrite;
assert.equal(process.exitCode, 1);
assert.match(stderrChunks.join(""), /desktop CLI passthrough mode/);
process.exitCode = previousExitCode;
process.env.PASEO_DESKTOP_CLI = previousDesktopCli;
console.log(" ✅ desktop CLI passthrough mode is rejected");
console.log("\n✅ Phase 32: Open Project CLI Tests PASSED");

View File

@@ -31,4 +31,7 @@ else
exit 1
fi
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js"
CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"

View File

@@ -10,5 +10,5 @@ if not exist "%APP_EXECUTABLE%" (
)
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel%

View File

@@ -1,3 +1,4 @@
npmRebuild: false
appId: sh.paseo.desktop
productName: Paseo
executableName: Paseo
@@ -6,9 +7,13 @@ directories:
output: release
files:
- dist/**/*
asarUnpack:
- dist/daemon/node-entrypoint-runner.js
extraResources:
- from: ../app/dist
to: app-dist
- from: ../../skills
to: skills
publish:
provider: github
owner: getpaseo

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.43",
"version": "0.1.47",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/cli": "0.1.47",
"@getpaseo/server": "0.1.47",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain } from "electron";
import log from "electron-log/main";
import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
import { resolvePaseoHome } from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
@@ -12,6 +12,12 @@ import {
writeAttachmentBase64,
} from "../features/attachments.js";
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
import {
installCli,
getCliInstallStatus,
installSkills,
getSkillsInstallStatus,
} from "../integrations/integrations-manager.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
@@ -21,10 +27,10 @@ import {
createNodeEntrypointInvocation,
resolveDaemonRunnerEntrypoint,
runCliJsonCommand,
runCliTextCommand,
} from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
const PID_POLL_INTERVAL_MS = 100;
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
@@ -41,6 +47,8 @@ type DesktopDaemonStatus = {
hostname: string | null;
pid: number | null;
home: string;
version: string | null;
desktopManaged: boolean;
error: string | null;
};
@@ -55,12 +63,6 @@ type DesktopPairingOffer = {
qr: string | null;
};
type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
// ---------------------------------------------------------------------------
@@ -71,10 +73,6 @@ function getPaseoHome(): string {
return resolvePaseoHome(process.env);
}
function pidFilePath(): string {
return path.join(getPaseoHome(), DAEMON_PID_FILENAME);
}
function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
@@ -164,41 +162,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim();
if (!normalized) {
return null;
}
if (
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(":")) {
return normalized;
}
return null;
}
function buildDaemonHttpBaseUrl(listen: string): string | null {
const endpoint = resolveTcpHostFromListen(listen);
if (!endpoint) {
return null;
}
return new URL(`http://${endpoint}`).toString().replace(/\/$/, "");
}
function resolveDesktopAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
@@ -223,57 +186,69 @@ function resolveDesktopAppVersion(): string {
// Daemon lifecycle
// ---------------------------------------------------------------------------
function resolveStatus(): DesktopDaemonStatus {
async function resolveStatus(): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
const pidPath = pidFilePath();
let pid: number | null = null;
let hostname: string | null = null;
let listen: string | null = null;
try {
if (existsSync(pidPath)) {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) {
pid = pidValue;
hostname = typeof parsed.hostname === "string" ? parsed.hostname : null;
const pidListen =
typeof parsed.listen === "string"
? parsed.listen
: typeof parsed.sockPath === "string"
? (parsed.sockPath as string)
: null;
listen = pidListen;
}
}
} catch {
// PID file missing or malformed — treat as stopped.
const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record<
string,
unknown
>;
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const running = localDaemon === "running";
return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status: running ? "running" : "stopped",
listen: typeof payload.listen === "string" ? payload.listen : null,
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
pid: running && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: payload.desktopManaged === true,
error: null,
};
} catch (error) {
return {
serverId: "",
status: "stopped",
listen: null,
hostname: null,
pid: null,
home,
version: null,
desktopManaged: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
const running = pid !== null && isProcessRunning(pid);
let serverId = "";
try {
serverId = getOrCreateServerId(home);
} catch {
// Ignore — server-id may not exist yet.
}
return {
serverId,
status: running ? "running" : "stopped",
listen,
hostname: running ? hostname : null,
pid: running ? pid : null,
home,
error: null,
};
function normalizeVersion(version: string | null): string | null {
const trimmed = version?.trim();
if (!trimmed) return null;
return trimmed.replace(/^v/i, "");
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = resolveStatus();
if (current.status === "running") return current;
const current = await resolveStatus();
if (current.status === "running") {
const appVersion = normalizeVersion(resolveDesktopAppVersion());
const daemonVersion = normalizeVersion(current.version);
if (
current.desktopManaged &&
appVersion &&
daemonVersion &&
appVersion !== daemonVersion
) {
logDesktopDaemonLifecycle("daemon version mismatch, restarting", {
appVersion,
daemonVersion,
});
await stopDaemon();
} else {
return current;
}
}
const daemonRunner = resolveDaemonRunnerEntrypoint();
const invocation = createNodeEntrypointInvocation({
@@ -296,7 +271,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
invocation.args,
{
detached: true,
env: invocation.env,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
},
);
@@ -348,7 +323,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
// Poll for PID file with server ID
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
const status = resolveStatus();
const status = await resolveStatus();
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
logDesktopDaemonLifecycle("polling daemon status after detached start", {
attempt: attempt + 1,
@@ -362,11 +337,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
await sleep(STARTUP_POLL_INTERVAL_MS);
}
return resolveStatus();
return await resolveStatus();
}
async function stopDaemon(): Promise<DesktopDaemonStatus> {
const status = resolveStatus();
const status = await resolveStatus();
if (status.status !== "running" || !status.pid) return status;
const pid = status.pid;
@@ -382,7 +357,7 @@ async function stopDaemon(): Promise<DesktopDaemonStatus> {
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
}
return resolveStatus();
return await resolveStatus();
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
@@ -398,8 +373,12 @@ function getDaemonLogs(): DesktopDaemonLogs {
};
}
async function getCliDaemonStatus(): Promise<string> {
return await runCliTextCommand(["daemon", "status"]);
}
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
const status = resolveStatus();
const status = await resolveStatus();
if (status.status !== "running") {
return {
relayEnabled: false,
@@ -409,7 +388,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
const payload = runCliJsonCommand(["daemon", "pair", "--json"]);
const payload = await runCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
@@ -428,86 +407,21 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
}
async function getLocalDaemonVersion(): Promise<{
version: string | null;
error: string | null;
}> {
const status = resolveStatus();
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
const status = await resolveStatus();
if (status.status !== "running") {
return {
version: null,
error: "Daemon is not running.",
};
return { version: null, error: "Daemon is not running." };
}
if (!status.listen) {
return { version: null, error: "Daemon listen target is unavailable." };
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };
}
try {
const response = await fetch(`${baseUrl}/api/status`);
if (!response.ok) {
return { version: null, error: `Daemon status request failed with ${response.status}` };
}
const payload = (await response.json()) as Record<string, unknown>;
const version = typeof payload.version === "string" ? payload.version.trim() : null;
return {
version: version && version.length > 0 ? version : null,
error: version ? null : "Running daemon did not report a version.",
};
} catch (error) {
return {
version: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function resolveCurrentUpdateVersion(): Promise<string> {
const daemonVersion = await getLocalDaemonVersion();
if (daemonVersion.version) {
return daemonVersion.version;
}
return resolveDesktopAppVersion();
}
function getCliSymlinkInstructions(): CliSymlinkInstructions {
const electronExePath = app.getPath("exe");
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
const cliPath = path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
};
}
if (process.platform === "win32") {
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return {
title: "Add paseo to your PATH",
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
commands: `setx PATH "%PATH%;${path.dirname(cliPath)}"`,
};
}
// Linux
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
version: status.version,
error: status.version ? null : "Running daemon did not report a version.",
};
}
function resolveCurrentUpdateVersion(): string {
return resolveDesktopAppVersion();
}
// ---------------------------------------------------------------------------
// IPC registration
// ---------------------------------------------------------------------------
@@ -520,7 +434,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(),
desktop_daemon_pairing: () => getDaemonPairing(),
cli_symlink_instructions: () => getCliSymlinkInstructions(),
cli_daemon_status: () => getCliDaemonStatus(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
@@ -548,9 +462,15 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
},
install_app_update: async () => {
const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion);
return downloadAndInstallUpdate(currentVersion, async () => {
await stopDaemon();
});
},
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
install_skills: () => installSkills(),
get_skills_install_status: () => getSkillsInstallStatus(),
};
}

View File

@@ -62,6 +62,20 @@ describe("node-entrypoint-launcher", () => {
).toEqual(["--version"]);
});
it("passes --open-project through as a normal CLI arg", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: [
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"--open-project",
"/tmp/project",
],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--open-project", "/tmp/project"]);
});
it("forces CLI mode for shim launches even without args", () => {
expect(
parseCliPassthroughArgsFromArgv({
@@ -81,15 +95,16 @@ describe("node-entrypoint-launcher", () => {
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
argvMode: "node-script",
args: ["ls", "--json"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"--disable-warning=DEP0040",
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"bare",
"node-script",
"/tmp/paseo-cli.js",
"ls",
"--json",
@@ -108,7 +123,7 @@ describe("node-entrypoint-launcher", () => {
isPackaged: false,
packagedRunnerPath: null,
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
argvMode: "node-script",
args: ["ls"],
baseEnv: { PATH: "/usr/bin" },
}),
@@ -136,6 +151,7 @@ describe("node-entrypoint-launcher", () => {
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"--disable-warning=DEP0040",
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"node-script",
"/tmp/paseo-cli.js",

View File

@@ -42,9 +42,14 @@ export function parseCliPassthroughArgsFromArgv(
input: ParseCliPassthroughArgsFromArgvInput,
): string[] | null {
const startIndex = input.isDefaultApp ? 2 : 1;
const effective = input.argv
.slice(startIndex)
.filter((arg) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p)));
const effective: string[] = [];
for (const arg of input.argv.slice(startIndex)) {
if (IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
continue;
}
effective.push(arg);
}
if (input.forceCli) {
return effective;
@@ -65,7 +70,7 @@ export function createNodeEntrypointInvocation(
return {
command: input.execPath,
args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
args: ["--disable-warning=DEP0040", input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
env,
};
}

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -76,7 +76,7 @@ function resolvePackagedAsarPath(): string {
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
return path.join(process.resourcesPath, "app.asar.unpacked", "dist", "daemon", "node-entrypoint-runner.js");
}
function assertPathExists(input: { label: string; filePath: string }): string {
@@ -223,7 +223,7 @@ function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
argvMode: "node-script",
args,
baseEnv: process.env,
});
@@ -246,30 +246,74 @@ export function runCliPassthroughCommand(args: string[]): number {
return result.signal ? 1 : 0;
}
export function runCliJsonCommand(args: string[]): unknown {
function spawnAsync(
command: string,
args: string[],
options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({ stdout, stderr, exitCode });
});
});
}
export async function runCliTextCommand(args: string[]): Promise<string> {
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
const result = await spawnAsync(invocation.command, invocation.args, {
env: invocation.env,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
}
if (result.status !== 0) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
return result.stdout.trimEnd();
}
export async function runCliJsonCommand(args: string[]): Promise<unknown> {
const invocation = createCliInvocation(args);
const result = await spawnAsync(invocation.command, invocation.args, {
env: invocation.env,
});
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
}
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
const stdout = result.stdout.trim();
if (stdout.length === 0) {
throw new Error("CLI command did not produce JSON output.");
}
// The stdout may contain non-JSON preamble (e.g. Node deprecation warnings).
// Extract the first valid JSON object or array from the output.
const jsonStart = stdout.search(/[{[]/);
if (jsonStart < 0) {
throw new Error("CLI command output contained no JSON.");
}
const jsonText = stdout.slice(jsonStart);
try {
return JSON.parse(stdout) as unknown;
return JSON.parse(jsonText) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,

View File

@@ -98,6 +98,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
export async function downloadAndInstallUpdate(
currentVersion: string,
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) {
return {
@@ -131,8 +132,9 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate();
// quitAndInstall restarts the app with the new version.
// Use a short delay to allow the renderer to receive the response.
setTimeout(() => {
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./cli-install-path";
describe("cli-install-path", () => {
it("uses the packaged executable on supported unix platforms", () => {
expect(
resolveCliInstallSourcePath({
platform: "darwin",
isPackaged: true,
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
}),
).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo");
});
it("prefers the original AppImage path on linux", () => {
expect(
resolveCliInstallSourcePath({
platform: "linux",
isPackaged: true,
executablePath: "/tmp/.mount_paseo123/paseo",
shimPath: "/tmp/.mount_paseo123/resources/bin/paseo",
appImagePath: "/home/user/Applications/Paseo.AppImage",
}),
).toBe("/home/user/Applications/Paseo.AppImage");
});
it("falls back to the shim on windows and in development", () => {
expect(
resolveCliInstallSourcePath({
platform: "win32",
isPackaged: true,
executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd",
}),
).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd");
expect(
resolveCliInstallSourcePath({
platform: "linux",
isPackaged: false,
executablePath: "/opt/Paseo/paseo",
shimPath: "/opt/Paseo/resources/bin/paseo",
}),
).toBe("/opt/Paseo/resources/bin/paseo");
});
});

View File

@@ -0,0 +1,24 @@
export function resolveCliInstallSourcePath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
executablePath: string;
shimPath: string;
appImagePath?: string | null;
}): string {
if (input.platform === "win32") {
return input.shimPath;
}
if (!input.isPackaged) {
return input.shimPath;
}
if (input.platform === "linux") {
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {
return appImagePath;
}
}
return input.executablePath;
}

View File

@@ -0,0 +1,283 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-loop",
"paseo-handoff",
"paseo-orchestrator",
"paseo-chat",
"paseo-committee",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.copyFile(installSourcePath, targetPath);
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
async function copySkillFile(sourceFile: string, destDir: string, skillName: string): Promise<void> {
const destSkillDir = path.join(destDir, skillName);
await fs.mkdir(destSkillDir, { recursive: true });
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
}
async function symlinkSkillDir(skillName: string, targetDir: string, linkParentDir: string): Promise<void> {
await fs.mkdir(linkParentDir, { recursive: true });
const target = path.join(targetDir, skillName);
const linkPath = path.join(linkParentDir, skillName);
if (await pathOrSymlinkExists(linkPath)) {
await fs.rm(linkPath, { recursive: true, force: true });
}
if (process.platform === "win32") {
try {
await fs.symlink(target, linkPath, "junction");
} catch {
await copySkillFile(path.join(target, "SKILL.md"), linkParentDir, skillName);
}
} else {
await fs.symlink(target, linkPath);
}
}
export async function installSkills(): Promise<InstallStatus> {
const sourceDir = getBundledSkillsDir();
const agentsDir = getAgentsSkillsDir();
const claudeDir = getClaudeSkillsDir();
const codexDir = getCodexSkillsDir();
log.info("[integrations] installSkills", { sourceDir, agentsDir, claudeDir, codexDir });
for (const skillName of SKILL_NAMES) {
const sourceFile = path.join(sourceDir, skillName, "SKILL.md");
await copySkillFile(sourceFile, agentsDir, skillName);
await symlinkSkillDir(skillName, agentsDir, claudeDir);
await copySkillFile(sourceFile, codexDir, skillName);
}
return getSkillsInstallStatus();
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
for (const skillName of SKILL_NAMES) {
const skillFile = path.join(claudeDir, skillName, "SKILL.md");
try {
await fs.access(skillFile);
} catch {
return { installed: false };
}
}
return { installed: true };
}

View File

@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { app, BrowserWindow, nativeImage, net, protocol } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
@@ -27,11 +27,31 @@ import {
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
app.setName("Paseo");
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
argv: process.argv,
isDefaultApp: process.defaultApp,
});
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
// 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;
return result;
});
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
@@ -126,6 +146,21 @@ async function createMainWindow(): Promise<void> {
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();
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
@@ -137,12 +172,21 @@ function setupSingleInstanceLock(): boolean {
return false;
}
app.on("second-instance", () => {
app.on("second-instance", (_event, commandLine) => {
log.info("[open-project] second-instance commandLine:", commandLine);
const openProjectPath = parseOpenProjectPathFromArgv({
argv: commandLine,
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);
}
}
});
@@ -168,7 +212,7 @@ async function runCliPassthroughIfRequested(): Promise<boolean> {
}
async function bootstrap(): Promise<void> {
if (await runCliPassthroughIfRequested()) {
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
return;
}

View File

@@ -0,0 +1,69 @@
import { mkdtempSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseOpenProjectPathFromArgv } from "./open-project-routing";
describe("open-project-routing", () => {
it("returns a bare absolute path argument", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", projectPath],
isDefaultApp: false,
}),
).toBe(projectPath);
});
it("finds a bare absolute path even when Chromium noise args appear first", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: [
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"--allow-file-access-from-files",
"--no-sandbox",
projectPath,
],
isDefaultApp: false,
}),
).toBe(projectPath);
});
it("does not treat flags as project paths", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
const flagLikeDirectory = path.join(projectPath, "--version");
mkdirSync(flagLikeDirectory);
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version", flagLikeDirectory],
isDefaultApp: false,
}),
).toBe(flagLikeDirectory);
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
}),
).toBeNull();
});
it("returns the path from an explicit --open-project flag for backward compatibility", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: [
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"--open-project",
projectPath,
],
isDefaultApp: false,
}),
).toBe(projectPath);
});
});

View File

@@ -0,0 +1,45 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
const OPEN_PROJECT_FLAG = "--open-project";
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
function isExistingDirectoryAbsolutePath(candidate: string): boolean {
if (!path.isAbsolute(candidate) || !existsSync(candidate)) {
return false;
}
try {
return statSync(candidate).isDirectory();
} catch {
return false;
}
}
export function parseOpenProjectPathFromArgv(input: {
argv: string[];
isDefaultApp: boolean;
}): string | null {
const effectiveArgs = input.argv
.slice(input.isDefaultApp ? 2 : 1)
.filter(
(arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)),
);
const positionalProjectPath = effectiveArgs.find(
(arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg),
);
if (positionalProjectPath) {
return positionalProjectPath;
}
const openProjectIndex = effectiveArgs.indexOf(OPEN_PROJECT_FLAG);
if (openProjectIndex === -1) {
return null;
}
const flaggedProjectPath = effectiveArgs[openProjectIndex + 1];
return flaggedProjectPath && isExistingDirectoryAbsolutePath(flaggedProjectPath)
? flaggedProjectPath
: null;
}

View File

@@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
invoke: (command: string, args?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:invoke", command, args),
getPendingOpenProject: () =>
ipcRenderer.invoke("paseo:get-pending-open-project") as Promise<string | null>,
events: {
on: (event: string, handler: EventHandler): Promise<() => void> => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.43",
"version": "0.1.47",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.43",
"version": "0.1.47",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.43",
"version": "0.1.47",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.43",
"version": "0.1.47",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -32,13 +32,13 @@
}
},
"scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "NODE_ENV=production node dist/server/server/index.js",
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
@@ -65,8 +65,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/relay": "0.1.43",
"@getpaseo/highlight": "0.1.47",
"@getpaseo/relay": "0.1.47",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -84,6 +84,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -108,6 +109,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
"typescript": "^5.2.2",

View File

@@ -38,7 +38,9 @@ import type {
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse,
ListProviderFeaturesResponseMessage,
ListProviderModelsResponseMessage,
ListProviderModesResponseMessage,
ListAvailableProvidersResponse,
ListTerminalsResponse,
CreateTerminalResponse,
@@ -158,6 +160,7 @@ export type DaemonClientConfig = {
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp";
appVersion?: string;
runtimeGeneration?: number | null;
authHeader?: string;
suppressSendErrors?: boolean;
@@ -221,12 +224,14 @@ type CreatePaseoWorktreePayload = Extract<
>["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
type ListCommandsOptions = {
requestId?: string;
@@ -1423,7 +1428,7 @@ export class DaemonClient {
const status = await this.sendRequest({
requestId,
message,
timeout: 15000,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -1727,6 +1732,35 @@ export class DaemonClient {
}
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_feature_request",
agentId,
featureId,
value,
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_feature_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentFeature rejected");
}
}
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
@@ -2496,6 +2530,37 @@ export class DaemonClient {
});
}
async listProviderModes(
provider: AgentProvider,
options?: { cwd?: string; requestId?: string },
): Promise<ListProviderModesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_modes_request",
provider,
cwd: options?.cwd,
},
responseType: "list_provider_modes_response",
timeout: 45000,
});
}
async listProviderFeatures(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
): Promise<ListProviderFeaturesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_features_request",
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
});
}
async listAvailableProviders(options?: {
requestId?: string;
}): Promise<ListAvailableProvidersPayload> {
@@ -3247,6 +3312,7 @@ export class DaemonClient {
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}),
);
} catch (error) {

View File

@@ -15,13 +15,16 @@
* This pattern is used in claude-agent.ts listModels().
*/
import { describe, it, expect } from "vitest";
import { describe, expect, test } from "vitest";
import {
query,
type Query,
type SlashCommand,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods
@@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => {
it("should return an array of SlashCommand objects", async () => {
test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
@@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => {
}
}, 30000);
it("should have valid SlashCommand structure for all commands", async () => {
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
@@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => {
});
describe("Command Execution", () => {
it("should explain that commands are prompts with / prefix", () => {
test("should explain that commands are prompts with / prefix", () => {
// This is a documentation test - commands ARE just prompts with / prefix
// To execute a command:
// 1. Create a user message with content: "/{commandName}"

View File

@@ -12,6 +12,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
AgentMode,
@@ -146,6 +147,7 @@ type ManagedAgentBase = {
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
pendingReplacement: boolean;
@@ -525,6 +527,31 @@ export class AgentManager {
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
} finally {
try {
await session.close();
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft feature listing session",
);
}
}
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;
@@ -781,6 +808,19 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = this.requireAgent(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
}
await agent.session.setFeature(featureId, value);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.touchUpdatedAt(agent);
this.emitState(agent);
}
async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
@@ -2474,6 +2514,10 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
if (agent.session?.features) {
agent.features = agent.session.features;
}
this.dispatch({
type: "agent_state",
agent: { ...agent },

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import type {
AgentFeature,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
@@ -111,6 +112,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides };
}
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
return {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: true,
...overrides,
};
}
describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
@@ -291,4 +302,13 @@ describe("toAgentPayload", () => {
const payload = toAgentPayload(agent);
expect(payload).not.toHaveProperty("lastUsage");
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
const payload = toAgentPayload(agent);
expect(payload.features).toEqual(features);
});
});

View File

@@ -98,6 +98,7 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
features: agent.features,
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null,
@@ -140,6 +141,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
const featureValues = sanitizeMetadata(config.featureValues);
if (featureValues !== undefined) {
serializable.featureValues = featureValues;
}
}
const extra = sanitizeMetadata(config.extra);
if (extra !== undefined) {
serializable.extra = extra;

View File

@@ -64,6 +64,29 @@ export type AgentSelectOption = {
metadata?: AgentMetadata;
};
export type AgentFeatureToggle = {
type: "toggle";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: boolean;
};
export type AgentFeatureSelect = {
type: "select";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: string | null;
options: AgentSelectOption[];
};
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export type AgentCapabilityFlags = {
supportsStreaming: boolean;
supportsSessionPersistence: boolean;
@@ -202,6 +225,10 @@ export type ToolCallDetail =
text?: string;
icon?: ToolCallIconName;
}
| {
type: "plan";
text: string;
}
| {
type: "unknown";
input: unknown | null;
@@ -368,6 +395,7 @@ export type AgentSessionConfig = {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
@@ -393,6 +421,7 @@ export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -409,12 +438,17 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
}
export interface ListModelsOptions {
cwd?: string;
}
export interface ListModesOptions {
cwd?: string;
}
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
@@ -428,6 +462,7 @@ export interface AgentClient {
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/**
* Check if this provider is available (CLI binary is installed).

View File

@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "./agent-storage.js";
import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js";
import type { ManagedAgent } from "./agent-manager.js";
import type {
AgentPermissionRequest,
@@ -41,6 +42,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
@@ -150,6 +154,62 @@ describe("AgentStorage", () => {
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
});
test("applySnapshot stores and reloads featureValues when present", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-feature-values",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
});
test("applySnapshot keeps featureValues absent when they were never set", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-no-feature-values",
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
});
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-resume-overrides",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-resume-overrides");
expect(record).not.toBeNull();
expect(buildConfigOverrides(record!)).toMatchObject({
cwd: "/tmp/project",
featureValues: {
fast_mode: true,
},
});
});
test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");

View File

@@ -16,6 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
featureValues: z.record(z.unknown()).nullable().optional(),
extra: z.record(z.any()).nullable().optional(),
systemPrompt: z.string().nullable().optional(),
mcpServers: z.record(z.any()).nullable().optional(),
@@ -71,6 +72,7 @@ export type SerializableAgentConfig = Pick<
| "modeId"
| "model"
| "thinkingOptionId"
| "featureValues"
| "extra"
| "systemPrompt"
| "mcpServers"

View File

@@ -1,32 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix,
applyProviderEnv,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies {
return {
execFileSync: vi.fn(),
execSync: vi.fn(),
existsSync: vi.fn(),
platform: vi.fn(() => "darwin"),
shell: undefined,
};
}
let findExecutableDependencies: FindExecutableDependencies;
beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies();
});
describe("resolveProviderCommandPrefix", () => {
test("uses resolved default command in default mode", () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
@@ -134,161 +113,3 @@ describe("applyProviderEnv", () => {
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
});
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(
"echo from profile\n/usr/local/bin/codex\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
});
test("warns and returns null when the final which line is not an absolute path", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});
test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
});
});
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});

View File

@@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
icon: "ShieldAlert",
colorTier: "moderate",
},
@@ -137,18 +129,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "haiku",
},
},
{
id: "claude-acp",
label: "Claude ACP",
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
@@ -157,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
modes: CODEX_MODES,
voice: {
enabled: true,
defaultModeId: "read-only",
defaultModeId: "auto",
defaultModel: "gpt-5.1-codex-mini",
},
},
@@ -200,6 +180,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: "build",
},
},
{
id: "pi",
label: "Pi",
description: "Minimal terminal-based coding agent with multi-provider LLM support",
defaultModeId: null,
modes: [],
},
];
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
@@ -210,12 +197,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
string,
...string[],
];
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);

View File

@@ -1,20 +1,22 @@
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
AgentProvider,
ListModelsOptions,
ListModesOptions,
} from "./agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
import type { Logger } from "pino";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -29,6 +31,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
createClient: (logger: Logger) => AgentClient;
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
}
type BuildProviderRegistryOptions = {
@@ -46,11 +49,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
logger,
runtimeSettings: runtimeSettings?.claude,
}),
"claude-acp": (logger, runtimeSettings) =>
new ClaudeACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["claude-acp"],
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp),
@@ -61,6 +59,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
@@ -86,6 +85,10 @@ export function buildProviderRegistry(
...definition,
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
fetchModes: (listOptions?: ListModesOptions) =>
modelClient.listModes
? modelClient.listModes(listOptions)
: Promise.resolve(definition.modes),
} satisfies ProviderDefinition,
];
}),

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