diff --git a/CHANGELOG.md b/CHANGELOG.md index 996e547ce..e4a97f1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 3d0188b55..155527cb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md new file mode 100644 index 000000000..12f543ad7 --- /dev/null +++ b/docs/MOBILE_TESTING.md @@ -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 + +``` + +```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 `` 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", + }, +})); + + +``` + +```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(); + + +``` + +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)). diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a7d07292d..b327db20f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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 ..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 diff --git a/nix/package.nix b/nix/package.nix index 0ddd5fb7a..eec96c76f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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). diff --git a/package-lock.json b/package-lock.json index 94d6fae67..e9cce9cb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6898a7ff4..12e0c3357 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.43", + "version": "0.1.47", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/maestro/.gitignore b/packages/app/maestro/.gitignore new file mode 100644 index 000000000..c7a365a58 --- /dev/null +++ b/packages/app/maestro/.gitignore @@ -0,0 +1,2 @@ +# Maestro takeScreenshot artifacts +*.png diff --git a/packages/app/maestro/sidebar-theme-repro.yaml b/packages/app/maestro/sidebar-theme-repro.yaml new file mode 100644 index 000000000..3c9dff9e9 --- /dev/null +++ b/packages/app/maestro/sidebar-theme-repro.yaml @@ -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 diff --git a/packages/app/maestro/test-sidebar-theme.sh b/packages/app/maestro/test-sidebar-theme.sh new file mode 100755 index 000000000..6a89f133b --- /dev/null +++ b/packages/app/maestro/test-sidebar-theme.sh @@ -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" diff --git a/packages/app/package.json b/packages/app/package.json index c1f5c3c69..bbf1ee2ca 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index af7d45145..e1c049678 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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(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("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() { + diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/components/agent-status-bar.test.ts index 4653bf4f8..5e4ffe505 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/components/agent-status-bar.test.ts @@ -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(); diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts index f242b00f2..baf563553 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -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): 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; diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 686cbf3e1..f29d1ca37 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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>({}); - useEffect(() => { - const prev = debugStreamPrevRef.current; - const curr: Record = { - // 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 ); - 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 = {}, - ) => ( - - {node.content} - - ), - textgroup: ( - node: any, - children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {children} - - ), - code_block: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - fence: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - code_inline: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( - - {children} - - ), - ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( - - {children} - - ), - 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 ( - - {marker} - {children} - - ); - }, - }; - }, []); - const permissionMutation = useMutation({ mutationFn: async (input: { agentId: string; @@ -897,50 +769,8 @@ function PermissionRequestCard({ ); } - return ( - - {title} - - {description ? ( - - {description} - - ) : null} - - {planMarkdown ? ( - - {!isPlanRequest ? ( - - Proposed plan - - ) : null} - - {planMarkdown} - - - ) : null} - - {!isPlanRequest ? ( - - ) : null} - + const footer = ( + <> + + ); + + if (isPlanRequest && planMarkdown) { + return ( + + ); + } + + return ( + + {title} + + {description ? ( + + {description} + + ) : null} + + {planMarkdown ? : null} + + {!isPlanRequest ? ( + + ) : null} + + {footer} ); } diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 5d22549c5..1ab3eb94c 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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 ( {/* Backdrop */} - - handleClose("backdrop-press")} - /> - + - {/* Resize handle - absolutely positioned over left border */} - - - + + + {/* Resize handle - absolutely positioned over left border */} + + + - handleClose("desktop-close-button")} - serverId={serverId} - workspaceId={workspaceId} - workspaceRoot={workspaceRoot} - isGit={isGit} - isMobile={false} - onOpenFile={onOpenFile} - /> + handleClose("desktop-close-button")} + serverId={serverId} + workspaceId={workspaceId} + workspaceRoot={workspaceRoot} + isGit={isGit} + isMobile={false} + onOpenFile={onOpenFile} + /> + ); } @@ -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, diff --git a/packages/app/src/components/icons/pi-icon.tsx b/packages/app/src/components/icons/pi-icon.tsx new file mode 100644 index 000000000..6dc97fa29 --- /dev/null +++ b/packages/app/src/components/icons/pi-icon.tsx @@ -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 ( + + + + + ); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ca7377e44..ab5b40371 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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 ( - - - + @@ -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 ( - + + {padding.top > 0 ? : null} @@ -796,32 +804,37 @@ function DesktopSidebar({ style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]} /> + ); } -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, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index eab286780..3bc5eacb3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( + + ); + } + return ( ( + + {node.content} + + ), + textgroup: ( + node: any, + children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {children} + + ), + code_block: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + fence: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + code_inline: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + + {children} + + ), + ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + + {children} + + ), + 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 ( + + {marker} + {children} + + ); + }, + }; +} + +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 ( + + {title} + {description ? ( + + {description} + + ) : null} + + {text} + + {footer ? {footer} : null} + + ); +} + +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], + }, +})); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index d0a10df1c..efa9c8ffd 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -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 = { 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 { diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index c12e023e3..a8a0adb04 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -84,6 +84,7 @@ interface SplitContainerProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | 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)} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index c539af754..db3196e03 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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({ ) : null); - return ( + const content = ( {trailingContent} : null} ); + + if (!tooltip) { + return content; + } + + return ( + + {content} + + {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", diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 32280504a..2441b14d4 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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({ ) : null); - return ( + const content = ( {trailingContent} : null} ); + + if (!tooltip) { + return content; + } + + return ( + + {content} + + {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", diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index 5b4f95948..2e19032bf 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -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 = ""; diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index de352a0e3..be54b0b97 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -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"} + {!showHostList && Platform.OS !== "web" && ( + <> + + You need the Paseo desktop app or server running on your computer first. + + openExternalUrl("https://paseo.sh")} + > + Get started at paseo.sh + + + + )} + {actions.map((action) => { const Icon = action.icon; diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index c6e1c3715..80d5c191d 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -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(undefined); const closeGestureRef = useRef(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, }} > diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 4e10c0361..f315eee6c 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -27,6 +27,8 @@ interface SidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -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(undefined); const closeGestureRef = useRef(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, ], ); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 86b385077..df0756eff 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -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(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); - const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [cliStatusMessage, setCliStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(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(null); - const [cliSymlinkInstructions, setCliSymlinkInstructions] = - useState(null); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); + const [cliStatusOutput, setCliStatusOutput] = useState(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 ( - - Built-in daemon + + Built-in daemon - - - Status - Only the built-in desktop daemon is shown here. + + + Status + Only the built-in desktop daemon is shown here. {daemonStatusStateText} @@ -348,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {showLifecycleControls ? ( <> - - - Daemon management - + + + Daemon management + {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"} - - - {daemonActionLabel} - {daemonActionMessage} + + + {daemonActionLabel} + {daemonActionMessage} {statusMessage ? {statusMessage} : null} - - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} + + + Log file + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( @@ -444,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. + + + Pair device + Connect your phone to this computer. + {daemonVersionMismatch ? ( @@ -469,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD ) : null} - setIsCliSymlinkModalOpen(false)} - title="Add paseo to your shell" - testID="managed-daemon-cli-symlink-dialog" - > - - - Paseo does not add the command for you. Run the command below in your terminal. - - {cliSymlinkInstructions?.detail ? ( - {cliSymlinkInstructions.detail} - ) : null} - - {cliSymlinkInstructions?.commands ?? ""} - - - - - - - - setIsPairingModalOpen(false)} @@ -518,12 +482,34 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD snapPoints={["70%", "92%"]} > - {daemonLogs?.logPath ?? "Log path unavailable."} + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} + + setIsCliStatusModalOpen(false)} + title="Daemon status" + testID="daemon-cli-status-dialog" + snapPoints={["60%", "85%"]} + > + + + {cliStatusOutput ?? ""} + + + + + + + ); } @@ -581,7 +567,7 @@ function PairingOfferDialogContent(input: { return ( - Loading pairing offer… + Loading pairing offer… ); } @@ -589,7 +575,7 @@ function PairingOfferDialogContent(input: { if (statusMessage) { return ( - {statusMessage} + {statusMessage} ); } @@ -597,21 +583,21 @@ function PairingOfferDialogContent(input: { if (!pairingOffer?.url) { return ( - Pairing offer unavailable. + Pairing offer unavailable. ); } return ( - + Scan this QR code in Paseo, or copy the pairing link below. {qrDataUrl ? ( ) : qrError ? ( - QR unavailable: {qrError} + QR unavailable: {qrError} ) : ( )} @@ -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, diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx new file mode 100644 index 000000000..cfc1b8510 --- /dev/null +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -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(null); + const [skillsStatus, setSkillsStatus] = useState(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 ( + + + Integrations + + + + + + + + + + + Command line + + + Control and script agents from your terminal. + + + {cliStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + + + Orchestration skills + + + Teach your agents to orchestrate through the CLI. + + + {skillsStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 5dbca01d8..9204dc7b3 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -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 { 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 { + const raw = await invokeDesktopCommand("cli_daemon_status"); + if (typeof raw !== "string") { + throw new Error("Unexpected CLI daemon status response."); } - return instructions; -} - -export async function getCliSymlinkInstructions(): Promise { - 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 { 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 { + return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status")); +} + +export async function installCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_cli")); +} + +export async function getSkillsInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status")); +} + +export async function installSkills(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_skills")); +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 57bd496f6..5c16bfcdd 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -79,6 +79,7 @@ export interface DesktopInvokeBridge { export interface DesktopHostBridge { platform?: string; invoke?: DesktopInvokeBridge["invoke"]; + getPendingOpenProject?: () => Promise; events?: DesktopEventsBridge; window?: DesktopWindowModuleBridge; dialog?: DesktopDialogBridge; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 8090c28a8..4abc88dd2 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -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 } : {}), }; } diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 518f0c372..5d39ba65d 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -16,6 +16,7 @@ export interface DraftCommandConfig { modeId?: string; model?: string; thinkingOptionId?: string; + featureValues?: Record; } 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; } diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 137985743..59a9d165e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -9,7 +9,6 @@ import { type ReadyState = Extract; type CatchingUpSyncState = Extract; -type SyncErrorSyncState = Extract; 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); }); }); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 39ecc4f87..e9b33d76f 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -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({ 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, }; } diff --git a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx deleted file mode 100644 index 3c1d1d772..000000000 --- a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx +++ /dev/null @@ -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 | 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: , - 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; - } - }; - }, []); -} diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts new file mode 100644 index 000000000..eaef93d6f --- /dev/null +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -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, + features: AgentFeature[], +): Record { + const allowedFeatureIds = new Set(features.map((feature) => feature.id)); + let changed = false; + const next: Record = {}; + + 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, +): 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>({}); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const normalizedCwd = cwd?.trim() || ""; + + const draftConfig = useMemo(() => { + 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, + }; +} diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index ad0a031cf..052a3d06b 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -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, ], diff --git a/packages/app/src/keyboard/focus-scope.test.ts b/packages/app/src/keyboard/focus-scope.test.ts new file mode 100644 index 000000000..5456493c0 --- /dev/null +++ b/packages/app/src/keyboard/focus-scope.test.ts @@ -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; + + 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"); + }); +}); diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts index 457db7e3b..340a8cd41 100644 --- a/packages/app/src/keyboard/focus-scope.ts +++ b/packages/app/src/keyboard/focus-scope.ts @@ -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"; } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 5afd566c4..dd7827e02 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 74c3eb08c..2ad3648e3 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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; 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((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(); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 073301177..65e64902a 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -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], diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c2e8235f6..c5fabd2da 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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["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; handleRemoveConnection: (serverId: string, connectionId: string) => Promise; 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 }, }); }} /> { @@ -291,7 +287,6 @@ function HostsSection(props: HostsSectionProps) { { @@ -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 ; case "about": return ; + case "integrations": + return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : 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 ( - setSelectedSectionId(section.id)} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - > - - + {showSeparator ? : null} + setSelectedSectionId(section.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} > - {section.label} - - + + + {section.label} + + + ); })} @@ -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( - null, - ); - const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState(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; 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({ /> ); })} - ) : 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, }, diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 7f64c7e55..09b5b07ac 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() { return ( - - Shortcuts + + Shortcuts {hasOverrides && (