Compare commits

...

55 Commits

Author SHA1 Message Date
Mohamed Boudra
fd894dc3d7 chore(release): cut 0.1.44 2026-04-03 16:55:14 +07:00
Mohamed Boudra
9ea181a072 docs: add 0.1.44 changelog entry 2026-04-03 16:55:05 +07:00
Mohamed Boudra
a96f2d7652 fix(desktop): stop daemon before auto-update restart
The daemon is a detached process that survives Electron restarts,
so after an auto-update the old daemon version would keep running.
Now we stop it before quitAndInstall so the new app instance
starts a fresh daemon with the updated binary.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
44da0c67b2 fix(server): disable claude-acp and copilot providers from registry
These providers cause old mobile clients (<=0.1.40) to fail parsing
the list_available_providers_response because their AgentProviderSchema
enum rejects unknown provider IDs, dropping the entire message and
leaving the model picker empty.

The provider implementations remain in the codebase — only the manifest
entries and factory registrations are removed until the updated mobile
app (0.1.43) is live in the App Store.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
55c4e58aa3 fix(desktop): disable npmRebuild in electron-builder 2026-04-03 16:53:14 +07:00
Mohamed Boudra
a2b1498c3f fix(app): broaden keyboard focus scope resolution to check multiple candidates
Check target, parentElement, and document.activeElement as focus
candidates instead of relying solely on the direct event target.
Fixes scope misdetection when the keyboard event target is a text
node or non-element.
2026-04-03 16:53:14 +07:00
github-actions[bot]
df617a4c8f fix: update lockfile signatures and Nix hash 2026-04-03 07:39:11 +00:00
Mohamed Boudra
a64292f2b0 fix: use cross-env for cross-platform NODE_ENV in server scripts 2026-04-03 14:38:07 +07:00
Mohamed Boudra
7ff5933b08 chore: update og-image.png 2026-04-03 11:16:57 +07:00
Mohamed Boudra
bb9ef76017 Fix OpenCode interrupt tool-call terminal state parity 2026-04-03 11:15:02 +07:00
Mohamed Boudra
d6413404e0 ci(desktop): add checkout step before running release tag script 2026-04-03 09:46:57 +07:00
Mohamed Boudra
8a585e60f2 fix(security): shell injection, symlink escape, remove /pairing endpoint, harden defaults
- Replace all execAsync shell-interpolated git calls with execFileAsync + array args in session.ts
- Add symlink resolution in file-explorer resolveScopedPath to prevent workspace escape
- Remove /pairing HTTP endpoint from server; desktop now uses `paseo daemon pair --json` via CLI
- Disable MCP HTTP endpoint by default (opt-in via config)
- Correct SECURITY.md: fix cipher name (XSalsa20-Poly1305), accurate replay resistance claims, add local daemon trust boundary docs
2026-04-02 23:58:38 +07:00
github-actions[bot]
1d795f6c32 fix: update lockfile signatures and Nix hash 2026-04-02 16:14:40 +00:00
Mohamed Boudra
9bd5f852e7 chore(release): cut 0.1.43 2026-04-02 23:13:07 +07:00
Mohamed Boudra
48516f0b9c docs(changelog): add 0.1.43 release notes 2026-04-02 23:12:26 +07:00
Mohamed Boudra
0bf8e8b5b2 Refine model selector UX and mobile sheet behavior
Closes #173
2026-04-02 22:58:59 +07:00
Mohamed Boudra
994ee488b9 Increase workspace status emphasis and use amber alert for needs input 2026-04-02 22:53:52 +07:00
Mohamed Boudra
a854096c35 feat: ACP base provider, Copilot integration, eliminate hardcoded provider unions (#170)
* feat(server): add ACP base provider with Claude ACP integration

Implement a generic Agent Client Protocol (ACP) base provider that any
ACP-compatible agent can extend with minimal code. Includes a concrete
Claude ACP implementation via @agentclientprotocol/claude-agent-acp
with full parity to the existing Claude Code provider.

The base handles subprocess lifecycle, streaming translation, permission
bridging, terminal/fs callbacks, listModels, loadSession/resume, and
mode/model management. The concrete class only specifies the command,
modes, and availability check.

* fix: update lockfile signatures and Nix hash

* feat: add Copilot ACP provider, eliminate hardcoded provider unions, fix ACP streaming bugs

Add Copilot as an ACP provider (copilot --acp), with real modes and models
discovered from the ACP server. Fix two ACP base bugs: duplicate assistant
text (emit deltas not cumulative) and idle→running→stuck (fire-and-return
startTurn). Replace all hardcoded provider string unions with string/manifest-
derived values so adding a provider only requires: impl class, manifest entry,
registry factory, icon, and E2E config. Add provider docs and Copilot icon.

* fix: update lockfile signatures and Nix hash

* feat(app): add OpenCode provider icon

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 23:20:01 +08:00
Mohamed Boudra
5d89f9444a Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 21:49:21 +07:00
thatdaveguy1
63905950cc feat(app): add searchable model favorites (#172)
Improve draft and live model selectors with search, favorites, and clearer provider context. Keep live agents honest by showing other provider catalogs as browse-only until provider switching exists.

Co-authored-by: David Longman <dlongman@tokentradegames.com>
2026-04-02 22:35:07 +08:00
Mohamed Boudra
ffd07ec17c fix(server): hardcode Claude models with 1M context support
The SDK's supportedModels() API hides the 1M context variant behind a
"default" alias and doesn't expose it as a selectable model. Replace
dynamic SDK discovery with a hardcoded catalog that exposes both
claude-opus-4-6[1m] (1M context) and claude-opus-4-6 (200k) as
distinct models. Delete sdk-model-resolver which parsed SDK descriptions.
2026-04-02 18:02:57 +07:00
Mohamed Boudra
2d63bc3893 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 14:39:25 +07:00
Mohamed Boudra
55acb8a539 fix(terminal): handle Ctrl+C/V for copy & paste on Windows/Linux
On non-Mac platforms, let xterm.js ClipboardAddon handle Ctrl+C (copy
when text is selected) and Ctrl+V (paste) instead of sending control
codes to the PTY.

Closes #175
2026-04-02 14:09:52 +07:00
Mohamed Boudra
4c52f272fd fix(server): implement slash command support for OpenCode harness (#169)
Add listCommands() to OpenCodeAgentSession via the OpenCode SDK's
command.list() API, and route recognized /commands through
session.command() instead of promptAsync(). This fixes issue #168
where slash commands didn't load when OpenCode was the selected harness.
2026-04-02 12:05:05 +08:00
Mohamed Boudra
a91f79053c fix: themed scrollbar on message input and reusable scrollbar hooks
Add useWebElementScrollbar (for DOM elements) and useWebScrollViewScrollbar
(for RN ScrollView/FlatList) hooks that return renderable overlays, replacing
manual WebDesktopScrollbarOverlay wiring across all consumers. Apply the
themed scrollbar to the message input textarea. Tint the dark-mode scrollbar
handle to match the teal-tinted dark theme.

Closes #174
2026-04-02 10:52:14 +07:00
github-actions[bot]
7b4db04a81 fix: update lockfile signatures and Nix hash 2026-04-02 02:45:23 +00:00
Mohamed Boudra
ac9c2c5642 chore(release): cut 0.1.43-rc.1 2026-04-02 09:44:03 +07:00
Mohamed Boudra
99200eabba fix(windows): quote shell args with spaces 2026-04-02 09:44:03 +07:00
github-actions[bot]
5f2bb87a17 fix: update lockfile signatures and Nix hash 2026-04-01 16:59:45 +00:00
Mohamed Boudra
897c18dd5f chore(release): cut 0.1.42 2026-04-01 23:58:00 +07:00
Mohamed Boudra
51a865cd24 docs(changelog): add 0.1.42 release notes 2026-04-01 23:58:00 +07:00
Mohamed Boudra
9dc3d116b4 fix(windows): quote command paths with spaces when spawning with shell
shell: true passes commands to cmd.exe /d /s /c which strips outer
quotes, causing paths like C:\Program Files\... to split at the space.
2026-04-01 23:58:00 +07:00
Mohamed Boudra
a4326ec5c0 Fix Claude bypass mode after query restarts
Closes #127
2026-04-01 23:58:00 +07:00
github-actions[bot]
cc09b61b19 fix: update lockfile signatures and Nix hash 2026-04-01 12:25:53 +00:00
Mohamed Boudra
26d69e2006 chore(release): cut 0.1.41 2026-04-01 19:23:40 +07:00
Mohamed Boudra
4b7c623592 docs(changelog): add 0.1.41 release notes 2026-04-01 19:21:48 +07:00
Mohamed Boudra
b91884bc54 fix(desktop): enable default context menu for copy/paste across the app 2026-04-01 19:19:40 +07:00
Mohamed Boudra
963c79265a fix(desktop): eliminate white flash on window resize in dark mode
Set native window backgroundColor to match the app's surface0 color so
the backing layer is dark during resize repaints. Extend the existing
updateWindowControls IPC to also call setBackgroundColor on all platforms
(including macOS), keeping the renderer as the single source of truth
for theme resolution. Add a prefers-color-scheme CSS rule in index.html
to cover the HTML-to-React mount gap.
2026-04-01 18:33:17 +07:00
Mohamed Boudra
ade1e338ea fix: show modifier keys and missing keys during shortcut rebinding
Add Tab, F1-F12, Delete, Home, End, PageUp, PageDown, Insert to the
key map so they can be captured during rebinding. Show held modifier
keys (Ctrl, Alt, Shift, Cmd) as live feedback matching VS Code's
keydown-only approach — modifiers persist after release until the
next keypress. Cancel capture when navigating away from settings.
2026-04-01 18:32:31 +07:00
Mohamed Boudra
c13972c835 fix: replace Unix path assumptions with cross-platform isAbsolutePath
Windows daemon paths like C:\Users\... don't start with "/", breaking
the explorer sidebar, checkout status, terminals, and file attachments
when connected to a Windows daemon. Consolidate three private
isAbsolutePath implementations into a shared utility and use it
everywhere, with correct file URI conversion for Windows and UNC paths.
2026-04-01 18:20:02 +07:00
Mohamed Boudra
d8d04c545e docs(release): clarify retry tag rebuilds 2026-04-01 17:40:23 +07:00
Mohamed Boudra
613450bac8 fix: remove 40-item cap on activity curator timeline output
`paseo logs` was only showing the last 40 collapsed timeline items
due to DEFAULT_MAX_ITEMS. Setting to 0 disables the cap so the full
timeline is shown by default. --tail still works for limiting output.
2026-04-01 17:38:32 +07:00
Mohamed Boudra
cafff08a30 fix: rewrite titlebar drag to match VS Code's static pattern
Replace the stateful TitlebarDragRegion (hooks, ResizeObserver, IPC,
fullscreen tracking) with a pure static component — matching VS Code's
titlebar-drag-region exactly: position absolute, full size, no z-index,
no pointer-events, no state, no event listeners.

Remove TitlebarNoDragContent entirely — VS Code doesn't wrap content in
no-drag; interactive elements get no-drag from the global CSS backstop
in index.html.

Add drag regions to all header surfaces:
- ScreenHeader (sessions, workspace header)
- Left sidebar (traffic light area + header)
- Split container pane tabs
- Explorer sidebar header (Changes/Files tabs)

Fix workspace header empty space not draggable by changing
headerTitleContainer from flex: 1 to flexShrink: 1.
2026-04-01 17:36:26 +07:00
Mohamed Boudra
cb60f2a596 Fix Windows default shell for terminal creation 2026-04-01 17:25:41 +07:00
Mohamed Boudra
58d72bea87 refactor: migrate to VS Code titlebar drag region pattern 2026-04-01 16:00:33 +07:00
Mohamed Boudra
953f7898e7 docs(release): clarify workflow dispatch retries 2026-04-01 15:43:24 +07:00
Mohamed Boudra
fd06e109be fix(release): use bash for release env steps 2026-04-01 15:40:34 +07:00
Mohamed Boudra
ba1bb1646e docs(release): clarify stable vs rc flow 2026-04-01 15:37:07 +07:00
github-actions[bot]
3ee11efcd0 fix: update lockfile signatures and Nix hash 2026-04-01 08:36:00 +00:00
Mohamed Boudra
1930a8a2f2 chore(release): cut 0.1.41-rc.1 2026-04-01 15:33:41 +07:00
Mohamed Boudra
f7fd41a5f8 chore(release): add rc prerelease flow 2026-04-01 15:33:27 +07:00
Mohamed Boudra
3af5b0f031 Merge remote-tracking branch 'origin/main' into codex/rc-release-0.1.41 2026-04-01 15:21:26 +07:00
Mohamed Boudra
cce8dee21c fix(server): use shell on Windows for all provider spawns
Windows npm shims (e.g. C:\nvm4w\nodejs\codex) fail with ENOENT when
spawned without shell. Use `shell: true` on win32 for all provider
launches instead of the overly complex shouldUseWindowsShell function.
2026-04-01 15:19:28 +07:00
Mohamed Boudra
2d02db6ae0 fix(app): improve light mode theming
- Make PaseoLogo theme-aware (uses foreground color instead of hardcoded white)
- Add shadow tokens (sm/md/lg) to theme with per-scheme opacity, radius, and offset
- Replace all 16 hardcoded shadow instances with spreadable theme.shadow tokens
- Fix button icon color for default variant (use accentForeground, not foreground)
- Fix dark background flash on startup (root layout used hardcoded darkTheme)
- Add theme.colorScheme to replace fragile hex-string dark mode detection
- Add scrollbarHandle and surfaceWorkspace tokens to eliminate isDark branching
2026-04-01 14:24:17 +07:00
github-actions[bot]
66732a2f48 fix: update lockfile signatures and Nix hash 2026-04-01 06:43:36 +00:00
154 changed files with 7424 additions and 2211 deletions

View File

@@ -34,15 +34,33 @@ jobs:
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -107,24 +125,6 @@ jobs:
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,7 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:

View File

@@ -15,6 +15,7 @@ on:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:

View File

@@ -58,24 +58,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -110,24 +93,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -165,23 +130,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
@@ -277,24 +233,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -329,24 +268,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -378,24 +299,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -438,24 +342,6 @@ jobs:
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:

View File

@@ -19,11 +19,6 @@ on:
required: false
default: false
type: boolean
draft:
description: "Create missing release as draft."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
@@ -48,7 +43,6 @@ jobs:
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
INPUT_DRAFT: ${{ github.event.inputs.draft }}
shell: bash
run: |
set -euo pipefail
@@ -70,8 +64,4 @@ jobs:
args+=(--create-if-missing)
fi
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
args+=(--draft)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"

View File

@@ -1,5 +1,53 @@
# Changelog
## 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
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added

View File

@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
### Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).

View File

@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
## Cloud build + submit (EAS)
Tag pushes like `v0.1.0` trigger:
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash

359
docs/PROVIDERS.md Normal file
View File

@@ -0,0 +1,359 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude`, `codex`, `opencode`.
---
## ACP Provider Checklist
### 1. Create the provider class
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
```ts
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const MY_PROVIDER_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
},
// Add more modes as needed
];
type MyProviderClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
}
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
}
```
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
```ts
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
```
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
```ts
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "autonomous",
label: "Autonomous",
description: "Runs without prompting",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
```
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
```ts
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
// ... existing providers ...
{
id: "my-provider",
label: "My Provider",
description: "Short description of the provider",
defaultModeId: "default",
modes: MY_PROVIDER_MODES,
// Optional: enable voice
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "some-model",
},
},
];
```
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
// ... existing factories ...
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
```tsx
import Svg, { Path } from "react-native-svg";
interface MyProviderIconProps {
size?: number;
color?: string;
}
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
```ts
export const agentConfigs = {
// ... existing configs ...
"my-provider": {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"my-provider",
];
```
### 6. Run typecheck
```bash
npm run typecheck
```
This is required after every change per project rules.
---
## Direct Provider Checklist
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
2. Add to the provider manifest (same as ACP step 2 above)
3. Add factory to the registry (same as ACP step 3 above)
4. Add icon (same as ACP step 4 above)
5. Add E2E config (same as ACP step 5 above)
6. Run typecheck
---
## Testing
### Manual testing with the CLI
Start the daemon if not already running, then:
```bash
# Launch an agent with your provider
paseo run --provider my-provider
# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default
# List running agents
paseo ls -a -g
# Check if the provider reports models
paseo models --provider my-provider
```
### E2E test patterns
The E2E configs in `agent-configs.ts` expose two helpers:
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
---
## Gotchas
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.

View File

@@ -2,6 +2,13 @@
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
```bash
@@ -12,6 +19,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
@@ -21,27 +30,44 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Draft release flow
## Release candidate flow
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
```
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
```bash
# Desktop (all platforms)
@@ -54,21 +80,29 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
@@ -76,11 +110,23 @@ The website depends on the changelog to determine the latest download version. T
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **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.
## Completion checklist
- [ ] 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 `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
npmDepsHash = "sha256-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q=";
# 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).

74
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.40",
"version": "0.1.44",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -48,6 +48,15 @@
}
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz",
"integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
@@ -3510,6 +3519,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/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -15425,6 +15441,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",
@@ -34962,16 +34996,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.40",
"version": "0.1.44",
"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.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.44",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/server": "0.1.44",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35088,11 +35122,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.40",
"version": "0.1.44",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/relay": "0.1.44",
"@getpaseo/server": "0.1.44",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35133,11 +35167,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.40",
"version": "0.1.44",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/cli": "0.1.44",
"@getpaseo/server": "0.1.44",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35171,7 +35205,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.40",
"version": "0.1.44",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35372,7 +35406,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.40",
"version": "0.1.44",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35398,7 +35432,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.40",
"version": "0.1.44",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35414,13 +35448,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.40",
"version": "0.1.44",
"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.40",
"@getpaseo/relay": "0.1.40",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/relay": "0.1.44",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35458,6 +35493,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
@@ -35818,7 +35854,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.40",
"version": "0.1.44",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.44",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -41,18 +41,23 @@
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.40",
"version": "0.1.44",
"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.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.44",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/server": "0.1.44",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -19,6 +19,12 @@
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;

View File

@@ -17,7 +17,6 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
@@ -69,7 +68,7 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -477,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
@@ -495,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme]);
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
return (
<VoiceProvider>
@@ -603,6 +606,7 @@ function FaviconStatusSync() {
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
@@ -610,7 +614,7 @@ function RootStack() {
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
backgroundColor: theme.colors.surface0,
},
}}
>
@@ -654,8 +658,10 @@ function NavigationActiveWorkspaceObserver() {
}
export default function RootLayout() {
const { theme } = useUnistyles();
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>

View File

@@ -1,4 +1,5 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
if (isAbsolutePath(attachment.storageKey)) {
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
// UNC paths: \\server\share -> file://server/share
if (attachment.storageKey.startsWith("\\\\")) {
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
}
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
}
return attachment.storageKey;
},

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
});
it("converts Windows drive-letter paths to file URIs", () => {
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
});
it("converts UNC paths to host-based file URIs", () => {
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
});
it("passes through file URIs unchanged", () => {
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
});
it("passes through relative paths unchanged", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});

View File

@@ -1,4 +1,5 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
return path;
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
}
export function fileUriToPath(uri: string): string {

View File

@@ -1444,11 +1444,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},

View File

@@ -8,7 +8,12 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
toggleFavoriteModel,
useFormPreferences,
} from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,6 +30,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
@@ -42,6 +48,10 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking";
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = {
provider: string;
providerOptions?: StatusOption[];
@@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
};
export interface DraftAgentStatusBarProps {
@@ -142,6 +157,11 @@ function ControlledStatusBar({
onSelectThinkingOption,
disabled = false,
isModelLoading = false,
providerDefinitions,
allProviderModels,
canSelectModelProvider,
favoriteKeys = new Set<string>(),
onToggleFavoriteModel,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -205,6 +225,26 @@ function ControlledStatusBar({
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions],
);
const fallbackAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (!modelOptions || modelOptions.length === 0) {
return map;
}
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
id: option.id,
label: option.label,
})),
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions = providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -289,49 +329,36 @@ function ControlledStatusBar({
) : null}
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
@@ -454,73 +481,38 @@ function ControlledStatusBar({
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[
styles.sheetSelect,
modelDisabled && styles.disabledSheetSelect,
]}
pointerEvents="none"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
/>
</View>
) : null}
@@ -650,6 +642,35 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
},
});
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
const displayMode =
@@ -674,6 +695,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
@@ -693,6 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
onSelectMode={(modeId) => {
if (!client) {
return;
@@ -722,6 +749,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
@@ -775,6 +808,7 @@ export function DraftAgentStatusBar({
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -789,6 +823,10 @@ export function DraftAgentStatusBar({
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
@@ -803,6 +841,12 @@ export function DraftAgentStatusBar({
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
isLoading={isAllModelsLoading}
disabled={disabled}
/>
@@ -820,29 +864,29 @@ export function DraftAgentStatusBar({
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
const modelOptions: StatusOption[] = models.map((model) => ({
id: model.id,
label: model.label,
}));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}

View File

@@ -1127,14 +1127,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
...theme.shadow.sm,
},
scrollToBottomIcon: {
color: theme.colors.foreground,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
describe("combined model selector helpers", () => {
const providerDefinitions = [
{
id: "claude",
label: "Claude",
description: "Claude provider",
defaultModeId: "default",
modes: [],
},
{
id: "codex",
label: "Codex",
description: "Codex provider",
defaultModeId: "auto",
modes: [],
},
];
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet-4.6",
label: "Sonnet 4.6",
},
];
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
];
it("keeps enough data to search by model and provider name", async () => {
const rows = buildModelRows(providerDefinitions, new Map([
["claude", claudeModels],
["codex", codexModels],
]));
expect(rows).toEqual([
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
});
});

View File

@@ -1,22 +1,39 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Search,
Star,
} from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
type SelectorModelRow,
} from "./combined-model-selector.utils";
const INLINE_MODEL_THRESHOLD = 8;
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
type DrillDownView = { provider: string };
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
@@ -25,9 +42,408 @@ interface CombinedModelSelectorProps {
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
canSelectProvider?: (provider: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
}) => React.ReactNode;
disabled?: boolean;
}
interface SelectorContentProps {
view: SelectorView;
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
onBack?: () => void;
}
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function partitionRows(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
const favoriteRows: SelectorModelRow[] = [];
const regularRows: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favoriteRows.push(row);
continue;
}
regularRows.push(row);
}
return { favoriteRows, regularRows };
}
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
const grouped = new Map<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
for (const row of rows) {
const existing = grouped.get(row.provider);
if (existing) {
existing.rows.push(row);
continue;
}
grouped.set(row.provider, {
providerId: row.provider,
providerLabel: row.providerLabel,
rows: [row],
});
}
return Array.from(grouped.values());
}
function ModelRow({
row,
isSelected,
isFavorite,
disabled = false,
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider);
const isWeb = Platform.OS === "web";
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onToggleFavorite?.(row.provider, row.modelId);
},
[onToggleFavorite, row.modelId, row.provider],
);
const item = (
<ComboboxItem
label={row.modelLabel}
selected={isSelected}
disabled={disabled}
onPress={onPress}
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => (
<Star
size={16}
color={
isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable>
) : null
}
/>
);
if (!isWeb || !row.description) {
return item;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>{item}</View>
</TooltipTrigger>
<TooltipContent side="right" align="center" offset={4}>
<Text style={styles.tooltipText}>{row.description}</Text>
</TooltipContent>
</Tooltip>
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
if (favoriteRows.length === 0) {
return null;
}
return (
<View>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
{favoriteRows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
<View style={styles.separator} />
</View>
);
}
function GroupedProviderRows({
providerDefinitions,
groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
return (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function SelectorContent({
view,
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
onBack,
}: SelectorContentProps) {
const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions],
);
const scopedRows = useMemo(() => {
if (view.kind === "provider") {
return allRows.filter((row) => row.provider === view.providerId);
}
return allRows;
}, [allRows, view]);
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo(
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
[normalizedQuery, scopedRows],
);
const { favoriteRows, regularRows } = useMemo(
() => partitionRows(visibleRows, favoriteKeys),
[favoriteKeys, visibleRows],
);
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
return (
<View>
{view.kind === "provider" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
) : null}
<SearchInput
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
/>
) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
function ProviderBackButton({
providerId,
providerLabel,
onBack,
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (!onBack) {
return null;
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
@@ -35,48 +451,80 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
canSelectProvider = () => true,
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [isContentReady, setIsContentReady] = useState(isWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setView({ kind: "all" });
if (!open) {
setSearchQuery("");
}
},
[allProviderModels, selectedProvider],
[],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setView({ kind: "all" });
setSearchQuery("");
},
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedProviderLabel = useMemo(
() => resolveProviderLabel(providerDefinitions, selectedProvider),
[providerDefinitions, selectedProvider],
);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Select model";
const model = models.find((m) => m.id === selectedModel);
if (!models) {
return isLoading ? "Loading..." : "Select model";
}
const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
return;
}
if (!isOpen) {
setIsContentReady(false);
return;
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
return (
<>
@@ -90,14 +538,26 @@ export function CombinedModelSelector({
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
]}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
disabled,
isOpen,
})
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
</Pressable>
<Combobox
options={[]}
@@ -105,184 +565,46 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
{view === "groups" ? (
<GroupsView
{isContentReady ? (
<SelectorContent
view={view}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider });
setSearchQuery("");
}}
/>
) : (
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
onBack={() => {
setView("groups");
setSearchQuery("");
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector</Text>
</View>
)}
</Combobox>
</>
);
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function DrillDownModelView({
provider,
providerDefinitions,
models,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSelect,
onBack,
}: {
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
@@ -307,6 +629,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
height: "auto",
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
@@ -374,9 +701,37 @@ const styles = StyleSheet.create((theme) => ({
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
favoriteButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
favoriteButtonHovered: {
backgroundColor: theme.colors.surface2,
},
favoriteButtonPressed: {
backgroundColor: theme.colors.surface1,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
sheetLoadingState: {
minHeight: 160,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[2],
},
sheetLoadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -0,0 +1,50 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
export type SelectorModelRow = FavoriteModelRow;
export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
return modelLabel;
}
export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): SelectorModelRow[] {
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
for (const model of allProviderModels.get(definition.id) ?? []) {
rows.push({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
});
}
}
return rows;
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) {
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
}

View File

@@ -268,10 +268,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -0,0 +1,57 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
/**
* VS Code-style titlebar drag region for Electron.
*
* Copied from VS Code at commit daa0a70:
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
*
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
* no state, no event listeners. Interactive elements get no-drag from their own
* CSS (global backstop in index.html). The drag region never re-renders.
*
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
* On macOS, Electron handles edge resize natively.
*/
/**
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
return null;
}
return (
<>
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div
style={{
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}}
/>
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
}}
/>
</>
);
}

View File

@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
textContainer: {
flex: 1,

View File

@@ -20,7 +20,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,8 +133,11 @@ export function DraggableList<T>({
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -177,7 +180,6 @@ export function DraggableList<T>({
);
const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
@@ -193,12 +195,10 @@ export function DraggableList<T>({
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
onLayout={scrollbar.onLayout}
onContentSizeChange={scrollbar.onContentSizeChange}
onScroll={scrollbar.onScroll}
scrollEventThrottle={16}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
@@ -259,13 +259,7 @@ export function DraggableList<T>({
{ListFooterComponent}
</>
)}
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}

View File

@@ -18,6 +18,7 @@ import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -344,6 +345,7 @@ function SidebarContent({
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -434,6 +436,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
header: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",

View File

@@ -4,9 +4,6 @@ import {
ActivityIndicator,
FlatList,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View,
@@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -152,7 +146,9 @@ export function FileExplorerPane({
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const hasInitializedRef = useRef(false);
@@ -502,24 +498,6 @@ export function FileExplorerPane({
});
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -598,27 +576,16 @@ export function FileExplorerPane({
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
onContentSizeChange={
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{scrollbar.overlay}
</View>
)}
</View>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from "react";
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -7,17 +7,11 @@ import {
Text,
View,
Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import {
highlightCode,
darkHighlightColors,
@@ -119,13 +113,14 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
@@ -140,24 +135,6 @@ function FilePreviewBody({
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
@@ -197,13 +174,11 @@ function FilePreviewBody({
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -218,13 +193,7 @@ function FilePreviewBody({
</RNScrollView>
)}
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}
@@ -236,13 +205,11 @@ function FilePreviewBody({
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={{
@@ -252,13 +219,7 @@ function FilePreviewBody({
resizeMode="contain"
/>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}

View File

@@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
@@ -86,9 +83,8 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colorScheme === "dark";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
@@ -430,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
@@ -516,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onScroll(event);
}
scrollbar.onScroll(event);
},
[diffScrollbarMetrics, showDesktopWebScrollbar],
[scrollbar.onScroll],
);
const handleDiffListLayout = useCallback(
@@ -530,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return;
}
diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onLayout(event);
}
scrollbar.onLayout(event);
},
[diffScrollbarMetrics, showDesktopWebScrollbar],
[scrollbar.onLayout],
);
const computeHeaderOffset = useCallback(
@@ -845,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
@@ -1086,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffContainer}>
{bodyContent}
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{hasChanges ? scrollbar.overlay : null}
</View>
</View>
);

View File

@@ -8,7 +8,8 @@ import {
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
@@ -45,8 +44,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
},
borderless && styles.borderless,
]}
{...dragHandlers}
>
<TitlebarDragRegion />
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</View>
@@ -61,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
},
inner: {},
row: {
position: "relative",
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,

View File

@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface CopilotIconProps {
size?: number;
color?: string;
}
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
<Path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="nonzero"
/>
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</Svg>
);
}

View File

@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface OpenCodeIconProps {
size?: number;
color?: string;
}
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
return (
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
/>
</Svg>
);
}

View File

@@ -1,16 +1,20 @@
import Svg, { Path } from "react-native-svg";
import { useUnistyles } from "react-native-unistyles";
interface PaseoLogoProps {
size?: number;
color?: string;
}
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
export function PaseoLogo({ size = 64, color }: PaseoLogoProps) {
const { theme } = useUnistyles();
const fill = color ?? theme.colors.foreground;
return (
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
<Path
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
fill={color}
fill={fill}
/>
</Svg>
);

View File

@@ -35,7 +35,8 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
@@ -638,7 +639,6 @@ function DesktopSidebar({
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -689,10 +689,13 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
</View>
@@ -831,6 +834,9 @@ const styles = StyleSheet.create((theme) => ({
width: 10,
zIndex: 10,
},
sidebarDragArea: {
position: "relative",
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,

View File

@@ -12,7 +12,15 @@ import {
Platform,
BackHandler,
} from "react-native";
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react";
import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import {
@@ -570,6 +579,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return null;
}, []);
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
if (!ref) return null;
@@ -911,42 +932,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
)}
{/* Text input */}
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
onFocusChange?.(false);
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
/>
<View style={styles.textInputScrollWrapper}>
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
onFocusChange?.(false);
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
/>
{inputScrollbar}
</View>
{/* Button row */}
<View style={styles.buttonRow}>
@@ -1187,6 +1211,9 @@ const styles = StyleSheet.create(((theme: any) => ({
removeImageButtonVisible: {
opacity: 1,
},
textInputScrollWrapper: {
position: "relative",
},
textInput: {
width: "100%",
color: theme.colors.foreground,

View File

@@ -269,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -1,10 +1,15 @@
import { Bot } from "lucide-react-native";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
"claude-acp": ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {

View File

@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
CircleAlert,
ChevronDown,
ChevronRight,
Copy,
@@ -73,7 +74,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -100,6 +101,10 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
@@ -238,6 +243,14 @@ function WorkspaceStatusIndicator({
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
@@ -247,6 +260,13 @@ function WorkspaceStatusIndicator({
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
@@ -258,6 +278,10 @@ function WorkspaceStatusIndicator({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -327,11 +351,26 @@ function ProjectLeadingVisual({
);
}
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.projectLeadingVisualSlot}>
@@ -343,6 +382,10 @@ function ProjectLeadingVisual({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -2022,11 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
projectRowLeft: {
flexDirection: "row",
@@ -2169,11 +2208,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface1,
@@ -2192,10 +2227,10 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: 7,
height: 7,
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},

View File

@@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -887,6 +888,7 @@ function SplitPaneView({
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
@@ -997,6 +999,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
paneTabs: {
position: "relative",
minWidth: 0,
},
paneContent: {

View File

@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style";
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
scrollbar-width: none;
-ms-overflow-style: none;
}
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
`;
import { useWebElementScrollbar } from "./use-web-scrollbar";
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const [followOutput, setFollowOutputr] = useState(true);
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange(true);
return;
}
streamScrollbarMetrics.onContentSizeChange(
scrollContainer.clientWidth,
scrollContainer.scrollHeight,
);
streamScrollbarMetrics.onLayout({
nativeEvent: {
layout: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
x: 0,
y: 0,
},
},
} as never);
streamScrollbarMetrics.onScroll({
nativeEvent: {
contentOffset: { x: 0, y: scrollContainer.scrollTop },
contentSize: {
width: scrollContainer.clientWidth,
height: scrollContainer.scrollHeight,
},
layoutMeasurement: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
},
},
} as never);
syncNearBottom(scrollContainer, onNearBottomChange);
const currentMetrics = {
scrollTop: scrollContainer.scrollTop,
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...currentMetrics,
});
}
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]);
}, [onNearBottomChange, props.agentId]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return (
<>
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
<div
ref={(node) => {
scrollContainerRef.current = node;
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={false}
onScrollToOffset={(nextOffset) => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
updateScrollMetrics();
}}
/>
{scrollbarOverlay}
</>
);
}

View File

@@ -259,11 +259,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
toastSuccess: {
borderColor: theme.colors.border,

View File

@@ -248,11 +248,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
detailLabel: {
color: theme.colors.foreground,
@@ -275,11 +271,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
scrollView: {
flexGrow: 0,

View File

@@ -136,9 +136,11 @@ export function Button({
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const color = variant === "default"
? theme.colors.accentForeground
: variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function

View File

@@ -65,6 +65,7 @@ export interface ComboboxProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -145,8 +146,10 @@ export interface ComboboxItemProps {
description?: string;
kind?: "directory" | "file";
leadingSlot?: ReactNode;
trailingSlot?: ReactNode;
selected?: boolean;
active?: boolean;
disabled?: boolean;
onPress: () => void;
testID?: string;
}
@@ -156,8 +159,10 @@ export function ComboboxItem({
description,
kind,
leadingSlot,
trailingSlot,
selected,
active,
disabled,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -178,12 +183,14 @@ export function ComboboxItem({
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={onPress}
style={({ pressed, hovered = false }) => [
styles.comboboxItem,
hovered && styles.comboboxItemHovered,
pressed && styles.comboboxItemPressed,
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
]}
>
{leadingContent}
@@ -197,9 +204,12 @@ export function ComboboxItem({
</Text>
) : null}
</View>
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot}
</View>
) : null}
</Pressable>
@@ -233,6 +243,7 @@ export function Combobox({
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
@@ -642,6 +653,7 @@ export function Combobox({
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -777,10 +789,18 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemActive: {
backgroundColor: theme.colors.surface1,
},
comboboxItemDisabled: {
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemTrailingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: "auto",
},
comboboxItemContent: {
@@ -845,11 +865,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},

View File

@@ -722,11 +722,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
overflow: "hidden",
},
sheetBackground: {

View File

@@ -605,11 +605,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
labelContainer: {
paddingHorizontal: theme.spacing[3],

View File

@@ -535,11 +535,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
...theme.shadow.sm,
zIndex: 1000,
},
}));

View File

@@ -0,0 +1,155 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
} from "react-native";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
function ensureHideScrollbarStyle(): void {
if (typeof document === "undefined") return;
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent =
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
document.head.appendChild(style);
}
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
return (
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
);
}
// ── DOM element scrollbar ────────────────────────────────────────────
// Fully automatic: listens to scroll/input/resize events on the element,
// hides the native scrollbar, and returns a themed overlay or null.
export function useWebElementScrollbar(
elementRef: RefObject<HTMLElement | null>,
options?: {
enabled?: boolean;
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
useEffect(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
element.setAttribute("data-hide-scrollbar", "");
(element.style as any).scrollbarWidth = "none";
(element.style as any).msOverflowStyle = "none";
ensureHideScrollbarStyle();
function update() {
const el = elementRef.current;
if (!el) return;
const next: ScrollbarMetrics = {
offset: el.scrollTop,
viewportSize: el.clientHeight,
contentSize: el.scrollHeight,
};
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
}
element.addEventListener("scroll", update, { passive: true });
element.addEventListener("input", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
const contentElement = contentRef?.current;
if (contentElement) {
resizeObserver.observe(contentElement);
}
update();
return () => {
element.removeEventListener("scroll", update);
element.removeEventListener("input", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
(element.style as any).msOverflowStyle = "";
};
}, [contentRef, elementRef, enabled]);
const onScrollToOffset = useCallback(
(offset: number) => {
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
},
[elementRef],
);
if (!enabled) return null;
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
}
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
// Returns event handlers to wire onto your ScrollView/FlatList plus
// a renderable overlay. The overlay is null when disabled.
interface WebScrollViewScrollbar {
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onLayout: (event: LayoutChangeEvent) => void;
onContentSizeChange: (width: number, height: number) => void;
overlay: ReactNode;
}
export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
(offset: number) => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
} else {
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
}
},
[scrollableRef],
);
const overlay: ReactNode = enabled ? (
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
) : null;
return {
onScroll: metricsHook.onScroll,
onLayout: metricsHook.onLayout,
onContentSizeChange: metricsHook.onContentSizeChange,
overlay,
};
}

View File

@@ -25,11 +25,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
buttonIdle: {
backgroundColor: theme.colors.surface2,

View File

@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
type ScrollbarMetrics = {
export type ScrollbarMetrics = {
offset: number;
viewportSize: number;
contentSize: number;
@@ -350,8 +350,7 @@ export function WebDesktopScrollbarOverlay({
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#181B1A";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleColor = theme.colors.scrollbarHandle;
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;

View File

@@ -320,7 +320,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}

View File

@@ -1,4 +1,8 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
import {
getDesktopHost,
type DesktopWindowBridge,
type DesktopWindowControlsOverlayUpdate,
} from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
export async function updateDesktopWindowControls(
update: DesktopWindowControlsOverlayUpdate,
): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
if (!win || typeof win.updateWindowControls !== "function") {
return;
}
await win.setTitleBarTheme(theme);
await win.updateWindowControls(update);
}

View File

@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
}) => Promise<void>;
}
export interface DesktopWindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
moving?: (screenX: number, screenY: number) => void;
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);

View File

@@ -128,11 +128,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
...theme.shadow.md,
maxWidth: 480,
},
closeButton: {

View File

@@ -1,4 +1,5 @@
import { getDesktopHost } from "@/desktop/host";
import { isAbsolutePath } from "@/utils/path";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
"svg",
];
function isAbsoluteWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value);
}
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
return uri.startsWith("file://") || isAbsolutePath(uri);
}
async function blobFromUri(uri: string): Promise<Blob> {

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
import {
buildFavoriteModelKey,
isFavoriteModel,
mergeProviderPreferences,
toggleFavoriteModel,
} from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
@@ -55,3 +60,92 @@ describe("mergeProviderPreferences", () => {
});
});
});
describe("favorite model preferences", () => {
it("builds a stable favorite key from provider and model", () => {
expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe(
"claude:sonnet-4.6",
);
});
it("adds a model to favorites without dropping other preferences", () => {
expect(
toggleFavoriteModel({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
},
},
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
});
});
it("removes a model from favorites when toggled again", () => {
expect(
toggleFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toEqual({
favoriteModels: [],
});
});
it("reports whether a model is favorited", () => {
expect(
isFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toBe(true);
expect(
isFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "claude",
modelId: "sonnet-4.6",
}),
).toBe(false);
});
});

View File

@@ -7,6 +7,20 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
@@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
).optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
@@ -66,6 +86,41 @@ export function mergeProviderPreferences(args: {
};
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({

View File

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

View File

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

View File

@@ -43,6 +43,17 @@ KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
KEY_MAP["ArrowUp"] = { code: "ArrowUp" };
KEY_MAP["ArrowDown"] = { code: "ArrowDown" };
KEY_MAP["Tab"] = { code: "Tab" };
KEY_MAP["Delete"] = { code: "Delete" };
KEY_MAP["Home"] = { code: "Home" };
KEY_MAP["End"] = { code: "End" };
KEY_MAP["PageUp"] = { code: "PageUp" };
KEY_MAP["PageDown"] = { code: "PageDown" };
KEY_MAP["Insert"] = { code: "Insert" };
for (let i = 1; i <= 12; i++) {
KEY_MAP[`F${i}`] = { code: `F${i}` };
}
const CODE_TO_KEY: Record<string, string> = {};
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
@@ -170,6 +181,15 @@ export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
return s.split(" ").map(comboStringToShortcutKeys);
}
export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
const parts: string[] = [];
if (event.ctrlKey) parts.push("Ctrl");
if (event.altKey) parts.push("Alt");
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
return parts.length > 0 ? parts.join("+") : null;
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null;

View File

@@ -11,9 +11,8 @@ import { AgentInputArea } from "@/components/agent-input-area";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import {
@@ -51,16 +50,14 @@ import {
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
}
if (provider === "codex") {
return "Codex";
}
if (!provider) {
return "Agent";
}
return provider.charAt(0).toUpperCase() + provider.slice(1);
return provider
.split(/[-_\s]+/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
@@ -96,7 +93,7 @@ function useAgentPanelDescriptor(
);
const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
const icon = getProviderIcon(provider) ?? Bot;
return {
label: label ?? "",

View File

@@ -51,7 +51,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -243,7 +243,6 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout,
);
const dragHandlers = useDesktopDragHandlers();
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId());
@@ -999,7 +998,8 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
const mainContent = (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View
@@ -1292,6 +1292,7 @@ function DraftAgentScreenContent({
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
flex: 1,
backgroundColor: theme.colors.surface0,
},

View File

@@ -9,7 +9,7 @@ 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 { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList);
@@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = isCompactFormFactor();
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isCompactLayout) {
@@ -29,7 +28,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content} {...dragHandlers}>
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
@@ -58,6 +58,7 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none",
},
content: {
position: "relative",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
@@ -13,18 +14,30 @@ import {
import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
heldModifiersFromEvent,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
function ShortcutSequence({
chord,
heldModifiers,
}: {
chord: string[] | null;
heldModifiers: string | null;
}) {
if ((!chord || chord.length === 0) && !heldModifiers) {
return <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
const displayCombos = [...(chord ?? [])];
if (heldModifiers) {
displayCombos.push(heldModifiers);
}
return <Shortcut chord={displayCombos.map(comboStringToShortcutKeys)} />;
}
function ShortcutRow({
@@ -33,6 +46,7 @@ function ShortcutRow({
overrideCombo,
isCapturing,
capturedCombos,
heldModifiers,
onRebind,
onDone,
onCancel,
@@ -43,6 +57,7 @@ function ShortcutRow({
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
heldModifiers: string | null;
onRebind: () => void;
onDone: () => void;
onCancel: () => void;
@@ -55,7 +70,7 @@ function ShortcutRow({
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<ShortcutSequence chord={capturedCombos} />
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
) : (
<Shortcut chord={displayChord} />
)}
@@ -88,22 +103,32 @@ function ShortcutRow({
export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isFocused = useIsFocused();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
useEffect(() => {
if (!isFocused && capturingBindingId !== null) {
cancelCapture();
}
}, [isFocused]);
function cancelCapture() {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
@@ -132,9 +157,11 @@ export function KeyboardShortcutsSection() {
const comboString = keyboardEventToComboString(event);
if (comboString === null) {
setHeldModifiers(heldModifiersFromEvent(event));
return;
}
setHeldModifiers(null);
setCapturedCombos((current) => [...current, comboString]);
}
@@ -194,6 +221,7 @@ export function KeyboardShortcutsSection() {
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
heldModifiers={capturingBindingId === bindingId ? heldModifiers : null}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);

View File

@@ -11,7 +11,7 @@ import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
type StartupSplashScreenProps = {
bootstrapState?: {
@@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
flex: 1,
alignItems: "center",
justifyContent: "center",
@@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
@@ -222,7 +222,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up</Text>
</View>
@@ -231,7 +232,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
@@ -253,7 +255,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
}
return (
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={[styles.container, styles.containerError]}>
<TitlebarDragRegion />
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />

View File

@@ -8,7 +8,6 @@ import {
Platform,
Pressable,
Text,
useColorScheme,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -63,6 +62,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { isAbsolutePath } from "@/utils/path";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import {
normalizeWorkspaceTabTarget,
@@ -578,8 +578,7 @@ function useCloseTabs(): UseCloseTabsResult {
function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast();
const isMobile = isCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
@@ -619,7 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
isAbsolutePath(normalizedWorkspaceId),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -688,7 +687,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const { archiveAgent } = useArchiveAgent();
useEffect(() => {
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) {
return;
}
@@ -720,7 +719,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
isAbsolutePath(normalizedWorkspaceId),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -762,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) {
return null;
}
return {
@@ -1140,7 +1139,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (createTerminalMutation.isPending) {
return;
}
if (!normalizedWorkspaceId.startsWith("/")) {
if (!isAbsolutePath(normalizedWorkspaceId)) {
return;
}
createTerminalMutation.mutate(input);
@@ -1367,7 +1366,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!normalizedWorkspaceId.startsWith("/")) {
if (!isAbsolutePath(normalizedWorkspaceId)) {
toast.error("Workspace path not available");
return;
}
@@ -2058,7 +2057,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
disabled={!normalizedWorkspaceId.startsWith("/")}
disabled={!isAbsolutePath(normalizedWorkspaceId)}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
@@ -2329,7 +2328,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",

View File

@@ -8,7 +8,7 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
export interface WorkspaceTabPresentation {
@@ -21,6 +21,11 @@ export interface WorkspaceTabPresentation {
statusBucket: SidebarStateBucket | null;
}
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = -2;
const EMPHASIZED_STATUS_DOT_OFFSET = -3;
type WorkspaceTabPresentationResolverProps = {
tab: WorkspaceTabDescriptor;
serverId: string;
@@ -114,6 +119,13 @@ export function WorkspaceTabIcon({
bucket: presentation.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
const shouldShowLoader = shouldRenderSyncedStatusLoader({
bucket: presentation.statusBucket,
});
@@ -137,6 +149,10 @@ export function WorkspaceTabIcon({
{
backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -199,10 +215,10 @@ const styles = StyleSheet.create((theme) => ({
},
statusDot: {
position: "absolute",
right: -2,
bottom: -2,
width: 7,
height: 7,
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},

View File

@@ -112,11 +112,15 @@ const lightSemanticColors = {
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceWorkspace: "#ffffff", // Workspace main background
// Text
foreground: "#09090b",
foregroundMuted: "#71717a",
// Controls
scrollbarHandle: "#3f3f46", // zinc-700
// Borders - shifted one step lighter
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
@@ -182,11 +186,15 @@ const darkSemanticColors = {
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
// Text
foreground: "#fafafa",
foregroundMuted: "#A1A5A4",
// Controls
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders
border: "#252B2A",
borderAccent: "#2F3534",
@@ -310,18 +318,60 @@ const commonTheme = {
} as const;
export const darkTheme = {
colorScheme: "dark" as const,
colors: {
...darkSemanticColors,
palette: baseColors,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
},
...commonTheme,
} as const;
export const lightTheme = {
colorScheme: "light" as const,
colors: {
...lightSemanticColors,
palette: baseColors,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.04)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 16,
elevation: 4,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 8 },
shadowRadius: 24,
elevation: 8,
},
},
...commonTheme,
} as const;

View File

@@ -72,6 +72,11 @@ declare global {
}
}
const isMac =
typeof navigator !== "undefined" &&
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as any).platform ?? ""));
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
@@ -280,6 +285,10 @@ export class TerminalEmulatorRuntime {
return true;
}
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;

View File

@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
describe("readFiniteScreenPoint", () => {
it("returns finite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
screenX: 1280,
screenY: 720,
});
});
it("rejects NaN screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
});
it("rejects infinite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
});
it("rejects missing screen coordinates", () => {
expect(readFiniteScreenPoint(undefined)).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
});
});

View File

@@ -1,27 +0,0 @@
export type DesktopWindowScreenPoint = {
screenX: number;
screenY: number;
};
type ScreenPointInput =
| {
screenX?: unknown;
screenY?: unknown;
}
| null
| undefined;
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null;
}
return {
screenX: input.screenX,
screenY: input.screenY,
};
}

View File

@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { isInteractiveDesktopDragTarget } from "./desktop-window";
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
return {
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
} as unknown as EventTarget;
}
describe("isInteractiveDesktopDragTarget", () => {
it("treats focusable pressables as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[tabindex]"),
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it("treats semantic button targets as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[role='button']"),
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it("returns false for non-interactive targets", () => {
const target = createTarget({
matchesSelector: () => false,
});
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
});
it("returns false when the target does not support closest()", () => {
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import {
getIsElectronRuntimeMac,
getIsElectronRuntime,
@@ -9,120 +9,7 @@ import {
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isElectronRuntime } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error);
}
}
}
// ---------------------------------------------------------------------------
// Manual window dragging via pointer events.
// Mirrors the Tauri implementation: single pointerdown handler with
// double-click-to-maximize via timing, closest() for interactive check,
// and pointer capture for move tracking.
// ---------------------------------------------------------------------------
const INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, " +
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
"[role='menuitem'], [tabindex], [contenteditable='true']";
const DOUBLE_CLICK_MS = 300;
type DesktopDragViewProps = Pick<
ViewProps,
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
>;
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return false;
}
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
}
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isElectronRuntime();
useEffect(() => {
if (!isActive) return;
function handleBlur() {
if (!isDragging.current) return;
isDragging.current = false;
getDesktopWindow()?.endMove?.();
}
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [isActive]);
return useMemo((): DesktopDragViewProps => {
if (!isActive) return {};
function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return;
isDragging.current = false;
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
const el = e.currentTarget as unknown as Element | null;
if (el && "releasePointerCapture" in el) {
el.releasePointerCapture(e.nativeEvent.pointerId);
}
getDesktopWindow()?.endMove?.();
}
return {
onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return;
if (isInteractiveDesktopDragTarget(e.target)) return;
e.preventDefault();
const now = Date.now();
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0;
void toggleMaximize();
return;
}
lastPointerDownAt.current = now;
const win = getDesktopWindow();
if (!win?.startMove) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) return;
isDragging.current = true;
const el = e.currentTarget as unknown as Element;
el.setPointerCapture(e.nativeEvent.pointerId);
win.startMove(screenPoint.screenX, screenPoint.screenY);
},
onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) {
stopDrag(e);
return;
}
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
},
onPointerUp: stopDrag,
onPointerCancel: stopDrag,
};
}, [isActive]);
}
type RawWindowControlsPadding = {
left: number;

View File

@@ -1,14 +1,10 @@
import { isAbsolutePath } from "./path";
interface BuildAbsoluteExplorerPathInput {
workspaceRoot: string;
entryPath: string;
}
function isAbsolutePath(pathValue: string): boolean {
return (
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
);
}
export function buildAbsoluteExplorerPath({
workspaceRoot,
entryPath,

View File

@@ -1,3 +1,5 @@
import { isAbsolutePath } from "./path";
export interface InlinePathTarget {
raw: string;
path: string;
@@ -183,7 +185,7 @@ export function parseAssistantFileLink(
};
}
if (!trimmed.startsWith("/")) {
if (!isAbsolutePath(trimmed)) {
return null;
}
@@ -195,7 +197,7 @@ export function parseAssistantFileLink(
}
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !normalizedPath.startsWith("/")) {
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -334,7 +336,3 @@ function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { isAbsolutePath } from "./path";
describe("isAbsolutePath", () => {
it("returns true for Unix absolute paths", () => {
expect(isAbsolutePath("/")).toBe(true);
expect(isAbsolutePath("/home/user")).toBe(true);
expect(isAbsolutePath("/tmp/file.txt")).toBe(true);
});
it("returns true for Windows drive letter paths", () => {
expect(isAbsolutePath("C:\\Users")).toBe(true);
expect(isAbsolutePath("C:/Users")).toBe(true);
expect(isAbsolutePath("d:\\projects")).toBe(true);
});
it("returns true for UNC paths", () => {
expect(isAbsolutePath("\\\\server\\share")).toBe(true);
expect(isAbsolutePath("\\\\\\\\host\\path")).toBe(true);
});
it("returns false for relative paths", () => {
expect(isAbsolutePath("foo/bar")).toBe(false);
expect(isAbsolutePath("./relative")).toBe(false);
expect(isAbsolutePath("../parent")).toBe(false);
expect(isAbsolutePath("")).toBe(false);
expect(isAbsolutePath("file.txt")).toBe(false);
});
it("returns false for edge cases that are not absolute paths", () => {
expect(isAbsolutePath("")).toBe(false);
expect(isAbsolutePath("C:")).toBe(false);
});
it("handles mixed separators in absolute paths", () => {
expect(isAbsolutePath("C:/Users\\mixed/path")).toBe(true);
expect(isAbsolutePath("/tmp\\mixed/path")).toBe(true);
});
});

View File

@@ -0,0 +1,5 @@
export function isAbsolutePath(value: string): boolean {
return (
value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
);
}

View File

@@ -25,3 +25,9 @@ export function getStatusDotColor(input: {
}
return null;
}
export function isEmphasizedStatusDotBucket(
bucket: SidebarStateBucket | null | undefined,
): boolean {
return bucket === "needs_input" || bucket === "attention";
}

View File

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

View File

@@ -412,7 +412,7 @@ export async function runRunCommand(
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
provider: resolvedProviderModel.provider,
cwd,
title: options.name,
modeId: options.mode,
@@ -511,7 +511,7 @@ export async function runRunCommand(
// Create the agent
const agent = await client.createAgent({
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
provider: resolvedProviderModel.provider,
cwd,
title: options.name,
modeId: options.mode,

View File

@@ -1,7 +1,12 @@
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
import {
getOrCreateServerId,
findExecutable,
quoteWindowsCommand,
applyProviderEnv,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -170,11 +175,12 @@ function checkProviderBinary(binary: string): { path: string | null; version: st
}
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(binaryPath, ["--version"], {
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
}).trim();
return { path: binaryPath, version: output || null };
} catch {

View File

@@ -18,9 +18,9 @@ export interface LoopRunRow {
}
export interface LoopRunOptions extends CommandOptions {
provider?: "claude" | "codex" | "opencode";
provider?: string;
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyProvider?: string;
verifyModel?: string;
verify?: string;
verifyCheck?: string[];

View File

@@ -45,11 +45,11 @@ export interface LoopRecord {
name: string | null;
prompt: string;
cwd: string;
provider: "claude" | "codex" | "opencode";
provider: string;
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerProvider: string | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierProvider: string | null;
verifierModel: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
@@ -122,11 +122,11 @@ export interface LoopStopPayload {
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: "claude" | "codex" | "opencode";
provider?: string;
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerProvider?: string;
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierProvider?: string;
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];

View File

@@ -1,5 +1,6 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
/** Provider list item for display */
export interface ProviderListItem {
@@ -9,27 +10,13 @@ export interface ProviderListItem {
modes: string;
}
/** Static provider data - providers are built-in and don't require daemon */
const PROVIDERS: ProviderListItem[] = [
{
provider: "claude",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
{
provider: "codex",
status: "available",
defaultMode: "auto",
modes: "read-only, auto, full-access",
},
{
provider: "opencode",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
];
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
status: "available",
defaultMode: def.defaultModeId ?? "default",
modes: def.modes.map((m) => m.label).join(", "),
}));
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {

View File

@@ -22,7 +22,7 @@ export type ScheduleTarget =
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
provider: string;
cwd: string;
modeId?: string;
model?: string;

View File

@@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = [];
const ctx = await createE2ETestContext({ timeout: 120000 });
async function runProviderModelsJson(
provider: "claude" | "codex" | "opencode",
provider: string,
): Promise<ProviderModel[]> {
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];

View File

@@ -50,7 +50,7 @@ async function cleanup(): Promise<void> {
}
async function runProviderCase(input: {
provider: "claude" | "codex" | "opencode";
provider: string;
mode: string;
model: string;
}): Promise<void> {

View File

@@ -1,3 +1,4 @@
npmRebuild: false
appId: sh.paseo.desktop
productName: Paseo
executableName: Paseo

View File

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

View File

@@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
const INTERACTIVE_SELECTOR = [
"button",
"a",
"input",
"textarea",
"select",
"[role='button']",
"[role='link']",
"[role='textbox']",
"[role='combobox']",
"[role='tab']",
"[role='switch']",
"[role='checkbox']",
"[role='slider']",
"[role='menuitem']",
"[tabindex]",
"[contenteditable='true']",
].join(", ");
function assert(condition, message) {
if (!condition) {
@@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) {
return filePath;
}
async function inspectTitlebarRegions(page) {
return page.evaluate((interactiveSelector) => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-titlebar-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
[data-electron-verify-resizer="true"] {
outline: 3px solid #52c41a !important;
outline-offset: -3px !important;
}
[data-electron-verify-interactive="true"] {
outline: 3px solid #1677ff !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
}
function readAppRegion(element) {
const style = window.getComputedStyle(element);
return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
}
function rectInfo(element) {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function summarizeElement(element) {
const style = window.getComputedStyle(element);
return {
tagName: element.tagName.toLowerCase(),
text: summarizeText(element),
appRegion: readAppRegion(element),
position: style.position,
zIndex: style.zIndex,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
...rectInfo(element),
};
}
function isNearTop(summary) {
return summary.top < 220;
}
function isTopResizer(element, overlayRect) {
if (!(element instanceof HTMLElement) || !isVisible(element)) {
return false;
}
const summary = summarizeElement(element);
return (
summary.appRegion === "no-drag" &&
summary.position === "absolute" &&
Math.abs(summary.height - 4) <= 1 &&
Math.abs(summary.top - overlayRect.top) <= 2 &&
Math.abs(summary.left - overlayRect.left) <= 2 &&
Math.abs(summary.width - overlayRect.width) <= 2
);
}
function summarizeInteractive(element) {
const summary = summarizeElement(element);
return {
...summary,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
};
}
const dragSummaries = [];
const suspiciousDragHosts = [];
for (const node of nodes) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue;
}
const summary = summarizeElement(node);
if (summary.appRegion !== "drag") {
continue;
}
const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null;
const parentSummary = parent ? summarizeElement(parent) : null;
const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.map((child) => summarizeInteractive(child));
const siblingResizers = parent
? Array.from(parent.children)
.filter((child) => child !== node)
.filter((child) => child instanceof HTMLElement)
.filter((child) => isTopResizer(child, summary))
.map((child) => summarizeElement(child))
: [];
const parentInteractive = parent
? Array.from(parent.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.map((child) => summarizeInteractive(child))
: [];
const explicitNoDragInteractive = parentInteractive.filter(
(child) => child.appRegion === "no-drag",
);
const record = {
...summary,
parent: parentSummary,
interactiveDescendants: interactiveDescendants.slice(0, 5),
siblingResizers: siblingResizers.slice(0, 3),
explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5),
parentInteractiveCount: parentInteractive.length,
};
dragSummaries.push(record);
const looksLikeHostShortcut =
isNearTop(summary) &&
(summary.position !== "absolute" ||
summary.text.length > 0 ||
interactiveDescendants.length > 0 ||
parentSummary?.appRegion === "drag");
if (looksLikeHostShortcut) {
suspiciousDragHosts.push(record);
}
}
const verifiedRegions = dragSummaries
.filter((entry) => isNearTop(entry))
.filter((entry) => entry.position === "absolute")
.filter((entry) => entry.text.length === 0)
.filter((entry) => entry.parent?.appRegion !== "drag")
.filter((entry) => entry.siblingResizers.length > 0)
.sort(
(left, right) =>
right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length ||
left.top - right.top ||
right.width - left.width,
);
const candidate = verifiedRegions[0] ?? null;
if (candidate) {
const matchingDragNode = nodes.find((node) => {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
return false;
}
const summary = summarizeElement(node);
return (
summary.appRegion === "drag" &&
Math.abs(summary.top - candidate.top) <= 1 &&
Math.abs(summary.left - candidate.left) <= 1 &&
Math.abs(summary.width - candidate.width) <= 1 &&
Math.abs(summary.height - candidate.height) <= 1
);
});
if (matchingDragNode instanceof HTMLElement) {
matchingDragNode.setAttribute("data-electron-verify-drag", "true");
const parent = matchingDragNode.parentElement;
if (parent instanceof HTMLElement) {
for (const child of parent.children) {
if (child instanceof HTMLElement && isTopResizer(child, candidate)) {
child.setAttribute("data-electron-verify-resizer", "true");
}
}
const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.filter((child) => summarizeElement(child).appRegion === "no-drag")
.slice(0, 3);
for (const child of interactiveChildren) {
child.setAttribute("data-electron-verify-interactive", "true");
}
}
}
}
return {
interactiveSelector,
dragRegionCount: dragSummaries.length,
verifiedRegionCount: verifiedRegions.length,
candidate,
suspiciousDragHosts: suspiciousDragHosts.slice(0, 10),
dragRegions: dragSummaries.slice(0, 10),
};
}, INTERACTIVE_SELECTOR);
}
async function inspectFullscreenResizer(page) {
const session = await page.context().newCDPSession(page);
let windowId = null;
let initialBounds = null;
let fullscreenEntered = false;
try {
const windowInfo = await session.send("Browser.getWindowForTarget");
windowId = windowInfo.windowId;
initialBounds = await session.send("Browser.getWindowBounds", { windowId });
await session.send("Browser.setWindowBounds", {
windowId,
bounds: { windowState: "fullscreen" },
});
fullscreenEntered = true;
await page.waitForTimeout(1000);
const details = await page.evaluate(async () => {
const bridge = window.paseoDesktop?.window;
const bridgeFullscreen =
typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null;
const visibleNoDragResizers = Array.from(document.querySelectorAll("*"))
.filter((node) => node instanceof HTMLElement)
.filter((node) => {
const element = node;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0" &&
appRegion === "no-drag" &&
style.position === "absolute" &&
Math.abs(rect.height - 4) <= 1 &&
rect.top < 220
);
})
.map((node) => {
const rect = node.getBoundingClientRect();
return {
tagName: node.tagName.toLowerCase(),
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
});
return {
bridgeFullscreen,
visibleNoDragResizers,
};
});
return {
supported: true,
enteredFullscreen: fullscreenEntered,
initialBounds,
...details,
passed:
details.bridgeFullscreen === true &&
Array.isArray(details.visibleNoDragResizers) &&
details.visibleNoDragResizers.length === 0,
};
} catch (error) {
return {
supported: false,
error: String(error),
initialBounds,
};
} finally {
const previousWindowState = initialBounds?.bounds?.windowState ?? "normal";
if (windowId !== null && fullscreenEntered) {
try {
await session.send("Browser.setWindowBounds", {
windowId,
bounds: { windowState: previousWindowState },
});
await page.waitForTimeout(500);
} catch {
// Best-effort restore only.
}
}
await session.detach().catch(() => undefined);
}
}
async function findAppPage(browser) {
for (let attempt = 0; attempt < 30; attempt += 1) {
for (const context of browser.contexts()) {
@@ -132,115 +457,20 @@ async function main() {
await page.waitForTimeout(500);
}
const dragRegionCheck = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-drag-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
}
const regions = [];
let candidate = null;
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
const text = summarizeText(element);
const info = {
tagName: element.tagName.toLowerCase(),
text,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
};
regions.push(info);
const looksLikeHeader =
rect.top < 180 &&
rect.height >= 40 &&
(text.includes("Settings") || text.includes("Sessions"));
if (!candidate && looksLikeHeader) {
candidate = { ...info };
element.setAttribute("data-electron-verify-drag", "true");
}
}
if (!candidate && regions.length > 0) {
candidate = regions.slice().sort((left, right) => left.top - right.top)[0];
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
if (
Math.abs(rect.top - candidate.top) < 1 &&
Math.abs(rect.left - candidate.left) < 1 &&
Math.abs(rect.width - candidate.width) < 1 &&
Math.abs(rect.height - candidate.height) < 1
) {
element.setAttribute("data-electron-verify-drag", "true");
break;
}
}
}
return {
count: regions.length,
candidate,
regions: regions.slice(0, 10),
};
});
const dragRegionCheck = await inspectTitlebarRegions(page);
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
const dragRegionPassed =
dragRegionCheck.count > 0 &&
dragRegionCheck.dragRegionCount > 0 &&
dragRegionCheck.verifiedRegionCount > 0 &&
Boolean(dragRegionCheck.candidate) &&
dragRegionCheck.candidate.top < 180;
dragRegionCheck.candidate.top < 220 &&
dragRegionCheck.candidate.parent?.appRegion !== "drag" &&
dragRegionCheck.candidate.siblingResizers.length > 0 &&
dragRegionCheck.suspiciousDragHosts.length === 0;
results.push({
check: "drag-regions",
check: "titlebar-drag-structure",
pass: dragRegionPassed,
details: dragRegionCheck,
screenshot: dragScreenshot,
@@ -248,7 +478,7 @@ async function main() {
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
const isMac = process.platform === "darwin";
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null;
const trafficLightPaddingPassed = !isMac
? true
: typeof observedPaddingLeft === "number" &&
@@ -261,12 +491,36 @@ async function main() {
details: {
platform: process.platform,
observedPaddingLeft,
expectedApproximatePaddingLeft: 78,
note: "Traffic-light padding is only validated structurally on macOS in this verifier.",
candidate: dragRegionCheck.candidate,
},
screenshot: trafficLightScreenshot,
});
const noDragInteractiveCheck = {
check: "interactive-no-drag-layering",
pass:
Boolean(dragRegionCheck.candidate) &&
Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) &&
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
details: {
candidate: dragRegionCheck.candidate,
explicitNoDragInteractive:
dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
},
screenshot: dragScreenshot,
};
results.push(noDragInteractiveCheck);
const fullscreenDetails = await inspectFullscreenResizer(page);
const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png");
results.push({
check: "fullscreen-resizer",
pass: fullscreenDetails.supported ? fullscreenDetails.passed : true,
details: fullscreenDetails,
screenshot: fullscreenScreenshot,
});
const daemonManagementVisible = await Promise.all([
page.getByText("Built-in daemon", { exact: true }).isVisible(),
page.getByText("Daemon management", { exact: true }).isVisible(),

View File

@@ -17,7 +17,11 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import {
createNodeEntrypointInvocation,
resolveDaemonRunnerEntrypoint,
runCliJsonCommand,
} from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -405,20 +409,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
if (!status.listen) {
throw new Error("Daemon listen target is unavailable.");
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
}
const response = await fetch(`${baseUrl}/pairing`);
if (!response.ok) {
throw new Error(`Daemon pairing request failed with ${response.status}`);
}
const payload = (await response.json()) as unknown;
const payload = runCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
@@ -557,7 +548,9 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
},
install_app_update: async () => {
const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion);
return downloadAndInstallUpdate(currentVersion, async () => {
await stopDaemon();
});
},
get_local_daemon_version: () => getLocalDaemonVersion(),
};

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -219,23 +219,22 @@ export function createNodeEntrypointInvocation(input: {
});
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
}
export function runCliPassthroughCommand(args: string[]): number {
const result = spawnCliProcess(args);
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
@@ -246,3 +245,34 @@ export function runCliPassthroughCommand(args: string[]): number {
return result.signal ? 1 : 0;
}
export function runCliJsonCommand(args: string[]): unknown {
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
}
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
if (stdout.length === 0) {
throw new Error("CLI command did not produce JSON output.");
}
try {
return JSON.parse(stdout) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

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

View File

@@ -13,9 +13,11 @@ import {
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
registerWindowManager,
getTitleBarOverlayOptions,
getMainWindowChromeOptions,
getWindowBackgroundColor,
resolveSystemWindowTheme,
setupWindowResizeEvents,
setupDefaultContextMenu,
setupDragDropPrevention,
} from "./window/window-manager.js";
import { registerDialogHandlers } from "./features/dialogs.js";
@@ -86,21 +88,19 @@ function applyAppIcon(): void {
}
async function createMainWindow(): Promise<void> {
const isMac = process.platform === "darwin";
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
backgroundColor: getWindowBackgroundColor(systemTheme),
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: "hidden",
...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
autoHideMenuBar: true,
}),
...getMainWindowChromeOptions({
platform: process.platform,
theme: systemTheme,
}),
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
@@ -109,6 +109,7 @@ async function createMainWindow(): Promise<void> {
});
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();

View File

@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
},
window: {
getCurrentWindow: () => ({
startMove: (screenX: number, screenY: number) =>
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
moving: (screenX: number, screenY: number) =>
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
endMove: () => ipcRenderer.send("paseo:window:endMove"),
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
setTitleBarTheme: (theme: "light" | "dark") =>
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
updateWindowControls: (update: {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);

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