Compare commits

..

81 Commits

Author SHA1 Message Date
Mohamed Boudra
60e833245d fix(desktop): add cleanup-assets job to delete stale release assets before rebuild 2026-03-21 15:56:01 +07:00
Mohamed Boudra
f424503234 fix(app): skip agent list refresh when screen is unfocused 2026-03-21 15:47:43 +07:00
Mohamed Boudra
1d42542514 refactor: extract isInteractiveDesktopDragTarget and add debug logging 2026-03-21 15:44:49 +07:00
Mohamed Boudra
d1314a4d5d Merge branch 'feat/unit-1-workspace-tab-store-navigation'
# Conflicts:
#	packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
#	packages/app/src/screens/workspace/workspace-agent-visibility.test.ts
#	packages/app/src/screens/workspace/workspace-agent-visibility.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
2026-03-21 15:27:35 +07:00
Mohamed Boudra
48c3308c31 refactor: move workspace tab navigation logic to prepareWorkspaceTab 2026-03-21 15:21:02 +07:00
Mohamed Boudra
ba149330b1 fix(desktop): fix release workflow publish target, signing, and Windows build
- Change electron-builder publish owner from anthropics to getpaseo
- Remove CSC_NAME (auto-discovered from cert, secret had rejected prefix)
- Remove CSC_IDENTITY_AUTO_DISCOVERY=false from build script (breaks Windows cmd.exe)
2026-03-21 15:02:40 +07:00
Mohamed Boudra
6731879931 fix(desktop): guard manual window drag coordinates 2026-03-21 14:58:57 +07:00
Mohamed Boudra
b15159caeb chore(release): cut 0.1.32 2026-03-21 14:47:53 +07:00
Mohamed Boudra
ec8c51a014 chore(release): cut 0.1.31 2026-03-21 14:46:58 +07:00
Mohamed Boudra
84d3aa1962 fix(desktop): update release workflow for Electron migration and add notarization
Fix desktop-release workflow to reference correct package path after
Tauri→Electron migration. Add macOS entitlements and notarization config
for electron-builder.
2026-03-21 14:46:26 +07:00
Mohamed Boudra
0bc693f157 feat(desktop): manual window dragging and agent tab pruning 2026-03-21 14:40:30 +07:00
Mohamed Boudra
5ae7a0c9c2 fix(app): reduce unnecessary workspace screen re-renders from unstable callback deps
- Add useCloseTabs hook to centralize tab close state with stable closeTab callback and reactive closingTabIds
- Replace killTerminalMutation/isArchivingAgent props with closingTabIds Set in split container
- Stabilize archiveAgent callback by depending on mutateAsync instead of whole mutation object
- Add bail-out to focusTabInLayout and focusPaneInLayout when already focused
- Remove redundant isArchivingAgent guard (closeTab handles dedup)
2026-03-21 14:29:55 +07:00
Mohamed Boudra
e6bc752b68 refactor(app): make keyboard shortcuts declarative and separate Cmd/Ctrl per platform
Replace imperative matches/when functions with declarative KeyCombo and
ShortcutWhen data structures, laying groundwork for a future rebinding UI.

Split all isMod (metaKey || ctrlKey) bindings into explicit platform pairs:
- Mac: Cmd (meta) only — Ctrl is never intercepted, passes through to terminal
- Linux/Windows: Ctrl — disabled when terminal is focused so Ctrl+W, Ctrl+K,
  etc. reach vim/shell
2026-03-21 14:29:37 +07:00
Mohamed Boudra
78fc1fe862 docs(server): document mapBlocksToTimeline role-awareness and user/assistant text mapping 2026-03-21 13:32:26 +07:00
Mohamed Boudra
84500cdcae fix(server): make mapBlocksToTimeline role-aware to prevent user text emitting as assistant_message
User messages with array content were routed through mapBlocksToTimeline
which hardcoded all text blocks as assistant_message. This caused user
interrupt text to appear as assistant output in paseo logs and the app UI.

Add textMessageType option so callers declare the role explicitly.
When set to "user_message", text blocks coalesce into a single item
matching extractUserMessageText semantics.
2026-03-21 13:31:11 +07:00
Mohamed Boudra
480af26b57 feat(desktop): add React DevTools extension for Electron dev mode
Downloads and loads React DevTools Chrome extension using Electron 41's
session.extensions API directly, avoiding the deprecated session.loadExtension
used by electron-devtools-installer. Extension is cached in userData after
first download. Only loaded when !app.isPackaged.
2026-03-21 13:05:24 +07:00
Mohamed Boudra
2003f308f3 fix(server): reconcile stale workspaces by pruning missing directories and fully-archived agents
Workspaces whose directories were deleted (e.g. cleaned-up worktrees) and
workspaces where every agent has been archived now get auto-archived during
reconciliation, cascading to project archival when no active siblings remain.

Extracts detectStaleWorkspaces() as a pure function in workspace-registry-model.ts
with standalone unit tests.
2026-03-21 13:04:57 +07:00
Mohamed Boudra
a042fbbe43 Keep explicitly reopened archived agent tabs open 2026-03-21 13:01:23 +07:00
Mohamed Boudra
ee5577c2da fix(app): remove background and border from host selector trigger
Show background only on hover for a cleaner default appearance.
2026-03-21 11:48:59 +07:00
Mohamed Boudra
24945a9498 feat(app): add line numbers, wrap toggle, and icon sizing improvements 2026-03-21 11:47:04 +07:00
Mohamed Boudra
f6f689d570 feat(app): show archived agent callout with unarchive button
When viewing an archived agent, display a callout in place of the input
area that matches the message input styling. The Unarchive button calls
refreshAgent which auto-unarchives server-side.
2026-03-21 11:46:35 +07:00
Mohamed Boudra
7703625e76 feat(app): redesign mobile tab switcher as secondary header row
Move tab switcher from bottom to top (after header), restyle as a
clean pressable row with chevron instead of count badge. Add "New
agent" and "New terminal" to header kebab menu. Fix BottomSheet
portal crash by nesting BottomSheetModalProvider inside QueryProvider.
2026-03-21 11:38:52 +07:00
Mohamed Boudra
17b81fb132 Merge branch 'main' into read-electron-migration-skills
# Conflicts:
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/left-sidebar.tsx
2026-03-21 01:52:25 +07:00
Mohamed Boudra
111576e2ca chore: finalize electron desktop migration 2026-03-21 01:50:00 +07:00
Mohamed Boudra
862cc43db7 feat: stream persisted history and show workspace kind indicators 2026-03-21 00:12:44 +07:00
Mohamed Boudra
48924626df fix(app): reduce workspace screen re-renders and fix archived agent handling
Replace greedy agents Map subscription with a Zustand selector that
derives workspace agent visibility (ID sets only) with custom equality,
so the workspace screen only re-renders when agents are added, removed,
or archived — not on every status/activity update.

Fix tab pruning to check against all known agents (including archived)
instead of only active agents, so archived agent tabs survive
reconciliation. Remove the auto-unarchive effect that called
refreshAgent() as a workaround. Hide input area for archived agents.
2026-03-20 21:07:51 +07:00
Mohamed Boudra
72ea9b7a72 fix(app): replace greedy host runtime subscriptions with targeted hooks
useHostRuntimeSession returned the full HostRuntimeSnapshot, causing
every consumer to rerender on any internal state change (probe cycles,
client generation bumps). Replace with targeted hooks that return
primitives/stable references so useSyncExternalStore skips rerenders
when the subscribed value hasn't actually changed.

New hooks: useHostRuntimeClient, useHostRuntimeConnectionStatus,
useHostRuntimeLastError, useHostRuntimeAgentDirectoryStatus,
useHostRuntimeIsDirectoryLoading. Existing useHostRuntimeIsConnected
already followed this pattern.

useHostRuntimeSnapshot kept only for settings-screen where probe
data is legitimately needed.
2026-03-20 19:16:06 +07:00
Mohamed Boudra
e0f9b33d23 refactor(app): drive agent panel focus from panes 2026-03-20 18:46:23 +07:00
Mohamed Boudra
c7fe944e73 Extract mobile sidebar and gesture wrappers 2026-03-20 18:44:57 +07:00
Mohamed Boudra
584f5ce05e refactor(app): extract agent panel content 2026-03-20 18:34:52 +07:00
Mohamed Boudra
614c085310 fix(app): reduce unnecessary re-renders in sidebar and workspace screens
- Gate WorkspaceScreen on useIsFocused to prevent background rendering
- Stabilize SidebarAnimationProvider context with useCallback/useMemo
- Wrap LeftSidebar in memo and memoize gesture/styles
- Extract inline style objects in AppContainer to stable references
2026-03-20 18:26:36 +07:00
Mohamed Boudra
866aeb8ad9 fix(app): extract favicon sync and add freezeOnBlur to screens 2026-03-20 17:29:46 +07:00
Mohamed Boudra
23aaecd99a fix(server): include actual error details in DaemonRpcError messages
The catch-all handler was replacing real error messages with "Request
failed", and DaemonRpcError only showed the error string without
requestType or code context.
2026-03-20 17:22:17 +07:00
Mohamed Boudra
77fb74c188 feat(app): redirect to next workspace after archiving 2026-03-20 13:08:28 +07:00
Mohamed Boudra
70fb5f5bfd fix(app): add worklet directives to working indicator functions
Functions called inside useAnimatedStyle must be marked as worklets.
Missing directives caused a native crash on Android with newer
react-native-reanimated/worklets that enforce UI-thread safety.
2026-03-20 12:19:22 +07:00
Mohamed Boudra
26c07b671f Fix archived workspace session routing\n\nCloses #128 2026-03-20 12:17:50 +07:00
Mohamed Boudra
c9f2e01131 desktop: fix AppImage patch step using absolute paths
The patch step cd's to a temp directory for extraction, so the
AppImage path must be absolute (via $GITHUB_WORKSPACE).
2026-03-20 00:41:41 +07:00
Mohamed Boudra
f4f3e4204d desktop: patch AppImage for Wayland compatibility
The linuxdeploy-plugin-gtk hook forces GDK_BACKEND=x11, which
prevents GTK initialization on Wayland-only systems. The bundled
libgdk-3.so already has Wayland support built in.

Add a post-build step that extracts the AppImage, comments out the
GDK_BACKEND=x11 line, and repackages with appimagetool.
2026-03-20 00:28:34 +07:00
Mohamed Boudra
d7d1e2d169 desktop: enable key repeat on macOS 2026-03-19 23:47:33 +07:00
Mohamed Boudra
6ab97c579e Simplify workspace creation with inline worktree API
Replace the multi-step new-agent route flow with a single
create_paseo_worktree endpoint that registers the workspace immediately
and creates the git worktree in the background. The sidebar now calls
this endpoint directly and shows a creating spinner inline.

Also auto-resolves the base branch from origin/HEAD when not explicitly
provided, removes the unused Tauri WebSocket transport layer, and
normalizes loopback endpoints to localhost.

Closes #125
2026-03-19 23:33:35 +07:00
Mohamed Boudra
3d4ac57bd0 Refactor project opening and add status bar tooltips 2026-03-19 19:04:01 +07:00
Mohamed Boudra
efb3df8233 fix desktop notification handling on macos 2026-03-19 16:43:34 +07:00
Mohamed Boudra
a952112910 Refactor agent sync toasts into panel host 2026-03-19 16:43:14 +07:00
Mohamed Boudra
20fa1a3a3b Fix workspace tab presentation hook ordering 2026-03-19 15:45:29 +07:00
Mohamed Boudra
7609ccee4c feat: expand diff syntax highlighting languages 2026-03-19 15:44:04 +07:00
Mohamed Boudra
9065dcef54 Fix daemon startup blocking on model downloads 2026-03-19 14:03:49 +07:00
Mohamed Boudra
4ab307b10f Add fetch tool details and improve sidebar shortcuts handling 2026-03-19 13:04:44 +07:00
Mohamed Boudra
675d825f6b chore(release): cut 0.1.30 2026-03-19 08:59:23 +07:00
Mohamed Boudra
1328e0cc05 docs(changelog): add 0.1.30 release notes 2026-03-19 08:58:33 +07:00
Mohamed Boudra
f0ba64f23b Add combined model selector and refactor working indicator 2026-03-19 08:55:40 +07:00
Mohamed Boudra
22c27dd583 Fix UI spacing and sizing in status bar and message input 2026-03-18 23:13:19 +07:00
Mohamed Boudra
35da664a09 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:54:09 +07:00
Mohamed Boudra
1323cb13c4 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:31:31 +07:00
Mohamed Boudra
3287f2d00b Only show bg on expanded file headers in git diff panel 2026-03-18 22:07:34 +07:00
Mohamed Boudra
d15a1451b7 Track active workspace from navigation state 2026-03-18 21:50:24 +07:00
Mohamed Boudra
9d370a18b8 Clean up git diff panel file header styles 2026-03-18 21:50:09 +07:00
Mohamed Boudra
865e25b3a9 Fix workspace tab drag hit area 2026-03-18 21:11:09 +07:00
Mohamed Boudra
82ab598426 Fix workspace route state syncing 2026-03-18 21:11:00 +07:00
Mohamed Boudra
09fae62888 Tighten terminal scrollbar gutter 2026-03-18 21:10:51 +07:00
Mohamed Boudra
7e0a220fde feat: improve terminal scrollbar and pane focus handling 2026-03-18 20:39:16 +07:00
Mohamed Boudra
ed9a15c0bd feat: add tab drop preview indicator for split pane drag-and-drop 2026-03-18 20:09:28 +07:00
Mohamed Boudra
b763d4358e feat: add terminal tabs and split pane controls to workspace UI 2026-03-18 19:55:04 +07:00
Mohamed Boudra
5f2d4ac122 chore(release): sync 0.1.29 versions 2026-03-18 19:47:30 +07:00
Mohamed Boudra
3bdc90a661 refactor: simplify tab layout to use equal widths with ideal clamping 2026-03-18 16:59:03 +07:00
Mohamed Boudra
b5212a69c9 refactor: improve split pane drag-and-drop and resize handle UX 2026-03-18 16:19:43 +07:00
Mohamed Boudra
0bc903fa21 Fix agent spawn PATH resolution and add provider binary checks to status
Shell env PATH was being overridden by process.env PATH in
applyProviderEnv, causing agent spawns to fail with ENOENT when the
daemon runs from the Tauri desktop app (minimal GUI PATH). Flip the
merge order so login shell env wins.

Add a Providers section to `paseo daemon status` that resolves each
agent binary (claude, codex, opencode) and runs --version using the
same applyProviderEnv environment the daemon uses to spawn agents.
2026-03-18 10:08:48 +07:00
Mohamed Boudra
e2f20f0e24 refactor: extract workspace layout actions and pane state logic 2026-03-18 09:39:12 +07:00
Mohamed Boudra
30a225ce9f Add split-pane layout store and navigation shortcuts 2026-03-18 01:36:54 +07:00
Mohamed Boudra
30dd54a318 refactor: extract panel registry and pane context from workspace screen
Replace hardcoded renderContent() switch and per-kind descriptor logic
with a registry-based panel interface. Each panel type (agent, draft,
terminal, file) now self-registers with its component, useDescriptor
hook, and optional confirmClose. Panels access workspace context via
usePaneContext() instead of prop drilling. This prepares the workspace
screen for split pane support.
2026-03-17 21:58:11 +07:00
Mohamed Boudra
abc8ad3fd4 refactor: migrate workspace tab actions to keyboard action dispatcher 2026-03-17 20:19:41 +07:00
Mohamed Boudra
4897627943 Fix Tauri tab close confirm reentry 2026-03-17 16:14:18 +07:00
Mohamed Boudra
18533ef52b style(app): add padding to terminal emulator 2026-03-17 14:51:17 +07:00
Mohamed Boudra
faa9c9c491 fix(app): start explorer panel closed and narrower on clean start 2026-03-17 14:20:32 +07:00
Mohamed Boudra
11f6494c20 fix: route Claude partial tool input through canonical mapping (#108) (#122)
* fix: show loading skeleton for pending tool call details

* fix: surface claude tool previews before full json

* fix: route claude partial tool input through canonical mapping

* fix: remove pending tool-call summary placeholder
2026-03-17 14:42:39 +08:00
Mohamed Boudra
9364da3414 fix(website): fix horizontal scroll on mobile
The APK tooltip with whitespace-nowrap and centered positioning
overflowed past the viewport on narrow screens. Anchor it to the
right on mobile, center it on sm+. Also stack nav logo and menu
vertically on mobile.
2026-03-16 18:44:31 +07:00
Mohamed Boudra
ed5bc3091e fix(app): shorten paths in project picker and close sidebar on workspace press 2026-03-15 20:16:41 +07:00
Mohamed Boudra
8ff51eb176 docs(release): add guide for retrying failed release builds 2026-03-15 14:34:15 +07:00
Mohamed Boudra
90fb5e1f33 fix(app): build workspace audio module during EAS install 2026-03-15 14:28:30 +07:00
Mohamed Boudra
cda08ec033 feat(website): add og:title and og:description via pageMeta helper
Extract a DRY pageMeta(title, description) helper that returns both
standard and OpenGraph meta tags from a single source of truth.
2026-03-15 14:28:10 +07:00
Mohamed Boudra
21c585abec chore(website): update OG image 2026-03-15 14:23:40 +07:00
Mohamed Boudra
a1d0492d8d feat(website): add OG image for social sharing
Copy og-image.png to public assets and add OpenGraph and Twitter Card
meta tags to the root layout so link previews work across all pages.
2026-03-15 14:15:51 +07:00
356 changed files with 23172 additions and 17388 deletions

View File

@@ -3,21 +3,21 @@ name: Desktop Release
on:
push:
tags:
- "v*"
- "desktop-v*"
- "desktop-macos-v*"
- "desktop-linux-v*"
- "desktop-windows-v*"
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build (e.g. v0.1.0)"
description: 'Existing tag to build (e.g. v0.1.0)'
required: true
type: string
platform:
description: "Optional desktop platform to build."
description: 'Optional desktop platform to build.'
required: false
default: "all"
default: 'all'
type: choice
options:
- all
@@ -31,18 +31,54 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop'
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
cleanup-assets:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- 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"
- name: Delete all existing release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
assets=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)
if [[ -z "$assets" ]]; then
echo "No existing assets to clean up"
exit 0
fi
for asset in $assets; do
echo "Deleting $asset"
gh release delete-asset "$RELEASE_TAG" "$asset" --repo "${{ github.repository }}" --yes || true
done
publish-macos:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
rust_target: aarch64-apple-darwin
electron_arch: arm64
- runner: macos-15-intel
rust_target: x86_64-apple-darwin
electron_arch: x64
permissions:
contents: write
packages: read
@@ -75,85 +111,39 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-macos-${{ matrix.rust_target }}
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Import Apple code-signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Sign bundled managed runtime
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
@@ -162,78 +152,38 @@ jobs:
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_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish macOS Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: tauri_build
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --target ${{ matrix.rust_target }}
- name: Notarize and re-upload DMG
if: env.IS_SMOKE_TAG != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
if [ -z "$dmg_path" ]; then
echo "::error::No DMG found in tauri build artifacts"
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
echo "DMG: $dmg_path"
echo "Signing DMG..."
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
echo "Submitting DMG for notarization..."
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
echo "Stapling notarization ticket..."
xcrun stapler staple "$dmg_path"
echo "Verifying..."
spctl --assess --type install --verbose "$dmg_path"
echo "Replacing release asset with notarized DMG..."
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
- name: Build macOS app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
publish-linux:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
@@ -267,89 +217,38 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-linux
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Strip CUDA dependencies from onnxruntime
- name: Set desktop package version from tag
shell: bash
run: |
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
echo "Removing needed $lib from $f"
patchelf --remove-needed "$lib" "$f"
done
done
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- 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'
@@ -359,99 +258,33 @@ jobs:
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_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build Linux Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: linux_tauri
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "1"
APPIMAGE_EXTRACT_AND_RUN: "1"
- name: Build desktop release
shell: bash
run: |
set -euo pipefail
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
- name: Attempt manual Linux AppImage fallback
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euxo pipefail
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
appdir_path="$appimage_dir/Paseo.AppDir"
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -n "$existing_appimage" ]; then
if [ "$existing_appimage" != "$canonical_appimage" ]; then
mv "$existing_appimage" "$canonical_appimage"
fi
if [ ! -f "$canonical_appimage.sig" ]; then
npx tauri signer sign "$canonical_appimage"
fi
exit 0
fi
if [ ! -d "$appdir_path" ]; then
echo "::error::AppDir was not generated at $appdir_path"
exit 1
fi
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
npx tauri signer sign "$canonical_appimage"
- name: Fail Linux release when AppImage bundling fails
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::Linux AppImage assets are still missing after the manual fallback."
exit 1
fi
- name: Upload Linux release assets
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
contents: write
@@ -485,76 +318,43 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-windows
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
@@ -563,31 +363,27 @@ jobs:
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_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish Windows Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: tauri-apps/tauri-action@v0
- name: Build desktop release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles nsis
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
- name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"

3
.gitignore vendored
View File

@@ -46,6 +46,9 @@ test-results/
# Vercel
.vercel/
# Expo
.expo/
# Misc
*.pem
.vercel

View File

@@ -1,5 +1,22 @@
# Changelog
## 0.1.30 - 2026-03-19
### Added
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
- Added a combined model selector and agent mode visuals across key UI surfaces.
- Added Open Graph metadata improvements for richer website sharing previews.
### Improved
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
- Improved project picker path display and general workspace UI polish.
### Fixed
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
## 0.1.28 - 2026-03-15
### Added
@@ -149,7 +166,7 @@
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
- Hid empty filter groups in the left sidebar.
### Fixed
@@ -171,7 +188,7 @@
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions.
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
- Improved desktop settings and permissions UX in Tauri.
- Improved desktop settings and permissions UX on desktop.
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
@@ -210,7 +227,7 @@
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16

View File

@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
- `packages/app` — Mobile + web client (Expo)
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
- `packages/relay` — E2E encrypted relay for remote access
- `packages/desktop`Tauri desktop wrapper
- `packages/desktop`Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Documentation

View File

@@ -44,7 +44,7 @@ Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Tauri desktop app
- `packages/desktop`: Electron desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)

View File

@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Tauri)
│ (Expo) │ │ (Commander) │ │ (Electron)
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
@@ -90,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Tauri)
### `packages/desktop` — Desktop app (Electron)
Tauri wrapper for macOS, Linux, and Windows.
Electron wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
@@ -180,5 +180,5 @@ $PASEO_HOME/
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Tauri app spawns daemon as subprocess
2. **Managed desktop**: Electron app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

View File

@@ -31,11 +31,30 @@ npm run release:finalize # Publish npm, promote draft to published
- `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
- Desktop assets now come from the Electron package at `packages/desktop`
## 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.
To retry a failed workflow for an existing tag:
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
```bash
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
gh workflow run "Android APK Release" -f tag=v0.1.28
gh workflow run "Deploy App" # no tag input needed
```
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
## 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
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time

3443
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.28",
"version": "0.1.32",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -56,7 +56,6 @@
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.5.3",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",

View File

@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "../../src/utils/host-routes";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -162,7 +159,7 @@ export async function seedBottomAnchorAgent(input: {
id: created.id,
title,
expectedTailText,
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}

View File

@@ -2,17 +2,10 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
attachConsole();
});
}
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";

View File

@@ -1,11 +1,13 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.28",
"version": "0.1.32",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
@@ -14,7 +16,6 @@
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -22,27 +23,33 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@getpaseo/expo-two-way-audio": "0.1.28",
"@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/server": "0.1.28",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -51,8 +58,6 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",

View File

@@ -1,12 +1,11 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test'
const baseURL =
process.env.E2E_BASE_URL ??
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -14,17 +13,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
reporter: [['list']],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
name: 'Desktop Safari',
use: { ...devices['Desktop Safari'] },
},
],
});
})

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* These styles make the root element full-height */
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
<style>
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'] {
-webkit-app-region: no-drag !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -1,6 +1,12 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -17,7 +23,7 @@ import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeSession,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
@@ -35,6 +41,7 @@ import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { UpdateBanner } from "@/desktop/updates/update-banner";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
@@ -46,11 +53,10 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -58,18 +64,18 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { attachConsole } from "@/utils/tauri-attach-console";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
@@ -78,33 +84,37 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? ((payload as { data: Record<string, unknown> }).data)
: undefined;
router.push(buildNotificationRoute(data) as any);
}
);
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
});
}
const target = globalThis as unknown as EventTarget;
@@ -118,7 +128,10 @@ function PushNotificationRouter() {
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
@@ -168,7 +181,7 @@ function PushNotificationRouter() {
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { client } = useHostRuntimeSession(daemon.serverId);
const client = useHostRuntimeClient(daemon.serverId);
if (!client) {
return null;
@@ -244,6 +257,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
interface AppContainerProps {
children: ReactNode;
selectedAgentId?: string;
@@ -257,23 +273,12 @@ function AppContainer({
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const horizontalScroll = useHorizontalScrollOptional();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useKeyboardShortcuts({
enabled: chromeEnabled,
@@ -282,6 +287,50 @@ function AppContainer({
selectedAgentId,
toggleFileExplorer,
});
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0]
);
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
{content}
</MobileGestureWrapper>
);
}
function MobileGestureWrapper({
children,
chromeEnabled,
}: {
children: ReactNode;
chromeEnabled: boolean;
}) {
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
@@ -290,18 +339,14 @@ function AppContainer({
animateToClose,
isGesturing,
} = useSidebarAnimation();
// Track initial touch position for manual activation
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
const openGesture = useMemo(
() =>
Gesture.Pan()
.enabled(openGestureEnabled)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
@@ -315,13 +360,11 @@ function AppContainer({
const deltaX = touch.absoluteX - touchStartX.value;
// If horizontal scroll is scrolled right, fail so ScrollView handles it
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
// Activate after 15px rightward movement
if (deltaX > 15) {
stateManager.activate();
}
@@ -330,7 +373,6 @@ function AppContainer({
isGesturing.value = true;
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
@@ -342,7 +384,6 @@ function AppContainer({
})
.onEnd((event) => {
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
@@ -362,36 +403,15 @@ function AppContainer({
animateToOpen,
animateToClose,
openAgentList,
mobileView,
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
]
);
const content = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1 }}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<GestureDetector gesture={openGesture} touchAction="pan-y">
{content}
{children}
</GestureDetector>
);
}
@@ -421,6 +441,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
{children}
</VoiceProvider>
);
@@ -467,12 +488,23 @@ function OfferLinkListener({
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
const hosts = useHosts();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
return;
}
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
@@ -499,6 +531,31 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
);
}
function FaviconStatusSync() {
useFaviconStatus();
return null;
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeState();
unsubscribeReady();
};
}, [navigationRef]);
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
@@ -555,11 +612,12 @@ export default function RootLayout() {
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<QueryProvider>
<BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ProvidersWrapper>
@@ -578,14 +636,16 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
options={{ freezeOnBlur: true }}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
options={{ gestureEnabled: false, freezeOnBlur: true }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/new-agent" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
@@ -596,8 +656,8 @@ export default function RootLayout() {
</SidebarAnimationProvider>
</ProvidersWrapper>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</BottomSheetModalProvider>
</BottomSheetModalProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>

View File

@@ -1,11 +1,11 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
const router = useRouter();
@@ -16,7 +16,8 @@ export default function HostAgentReadyRoute() {
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentCwd = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
@@ -38,7 +39,11 @@ export default function HostAgentReadyRoute() {
if (normalizedCwd) {
redirectedRef.current = true;
router.replace(
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -77,7 +82,13 @@ export default function HostAgentReadyRoute() {
const cwd = result?.agent?.cwd?.trim();
redirectedRef.current = true;
if (cwd) {
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any
);
return;
}
router.replace(buildHostRootRoute(serverId) as any);

View File

@@ -5,9 +5,9 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
@@ -59,11 +59,11 @@ export default function HostIndexRoute() {
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
buildHostWorkspaceAgentRoute(
prepareWorkspaceTab({
serverId,
primaryAgent.cwd.trim(),
primaryAgent.id
) as any
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any
);
return;
}

View File

@@ -1,9 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function HostNewAgentRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <DraftAgentScreen forcedServerId={serverId} />;
}

View File

@@ -1,25 +1,87 @@
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
parseHostWorkspaceRouteFromPathname,
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') {
return value.trim()
}
if (Array.isArray(value)) {
const firstValue = value[0]
return typeof firstValue === 'string' ? firstValue.trim() : ''
}
return ''
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') {
return { kind: 'agent', agentId: openIntent.agentId }
}
if (openIntent.kind === 'terminal') {
return { kind: 'terminal', terminalId: openIntent.terminalId }
}
if (openIntent.kind === 'file') {
return { kind: 'file', path: openIntent.path }
}
return { kind: 'draft', draftId: openIntent.draftId }
}
export default function HostWorkspaceLayout() {
const expoPathname = usePathname()
const params = useGlobalSearchParams<{ open?: string | string[] }>()
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
const serverId = activeRoute?.serverId ?? ''
const workspaceId = activeRoute?.workspaceId ?? ''
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
const openIntent = parseWorkspaceOpenIntent(openValue)
const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
}>()
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverId = getParamValue(params.serverId)
const workspaceValue = getParamValue(params.workspaceId)
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = getParamValue(globalParams.open)
useEffect(() => {
if (!openValue) {
return
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
if (consumedIntentRef.current === consumptionKey) {
return
}
consumedIntentRef.current = consumptionKey
const openIntent = parseWorkspaceOpenIntent(openValue)
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent',
})
: buildHostWorkspaceRoute(serverId, workspaceId)
router.replace(route as any)
}, [openValue, router, serverId, workspaceId])
if (openValue) {
return null
}
return (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
/>
)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
@@ -57,7 +57,7 @@ export default function Index() {
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);

View File

@@ -17,7 +17,7 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -25,7 +25,23 @@ import type {
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck,
ShieldAlert,
ShieldOff,
};
const MODE_COLOR_MAP: Record<string, string> = {
default: baseColors.blue[500],
safe: baseColors.green[500],
moderate: baseColors.amber[500],
dangerous: baseColors.red[500],
readonly: baseColors.purple[500],
};
type DropdownTriggerRenderProps = {
label: string;
@@ -563,6 +579,10 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
return (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
@@ -603,7 +623,7 @@ export function AgentConfigRow({
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
showLabel={false}
testID="draft-mode-select"
/>

View File

@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
selectedModel: '',
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
onSelectThinkingOption: () => undefined,

View File

@@ -28,7 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Shortcut } from '@/components/ui/shortcut'
import { Autocomplete } from '@/components/ui/autocomplete'
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import {
deleteAttachments,
persistAttachmentFromBlob,
@@ -97,17 +97,20 @@ export function AgentInputArea({
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
const buttonIconSize = Platform.OS === 'web' ? theme.iconSize.md : theme.iconSize.lg
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId)
const toast = useToast()
const voice = useVoiceOptional()
const isDictationReady =
isConnected &&
(snapshot?.agentDirectoryStatus === 'ready' ||
snapshot?.agentDirectoryStatus === 'revalidating' ||
snapshot?.agentDirectoryStatus === 'error_after_ready')
(agentDirectoryStatus === 'ready' ||
agentDirectoryStatus === 'revalidating' ||
agentDirectoryStatus === 'error_after_ready')
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
@@ -699,7 +702,7 @@ export function AgentInputArea({
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={theme.iconSize.lg} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -720,15 +723,16 @@ export function AgentInputArea({
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={[
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -885,8 +889,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
zIndex: 30,
},
cancelButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: 'center',
@@ -898,12 +902,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
gap: theme.spacing[2],
},
realtimeVoiceButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: 'center',
justifyContent: 'center',
},
@@ -911,6 +912,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -9,14 +9,14 @@ import {
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router, usePathname, type Href } from 'expo-router'
import { router } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
interface AgentListProps {
agents: AggregatedAgent[]
@@ -250,7 +250,6 @@ export function AgentList({
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles()
const pathname = usePathname()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
@@ -270,15 +269,17 @@ export function AgentList({
const serverId = agent.serverId
const agentId = agent.id
const shouldReplace = pathname.startsWith('/h/')
const navigate = shouldReplace ? router.replace : router.push
onAgentSelect?.()
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
navigate(route)
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: 'agent', agentId },
})
router.navigate(route as any)
},
[isActionSheetVisible, pathname, onAgentSelect]
[isActionSheetVisible, onAgentSelect]
)
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {

View File

@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest'
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
import {
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from './agent-status-bar.utils'
describe('getStatusSelectorHint', () => {
it('explains what each editable status control does', () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
expect(getStatusSelectorHint('model')).toBe('Change model')
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
})
})
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {

View File

@@ -1,7 +1,16 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
import {
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
} from 'lucide-react-native'
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 {
@@ -10,22 +19,34 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from '@/components/agent-status-bar.utils'
type StatusOption = {
id: string
label: string
}
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
@@ -53,6 +74,9 @@ export interface DraftAgentStatusBarProps {
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
@@ -72,7 +96,36 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
return selected?.label ?? fallback
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
): string {
switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
case 'readonly':
return palette.purple[500]
default:
return palette.blue[500]
}
}
function ControlledStatusBar({
provider,
providerOptions,
selectedProviderId,
onSelectProvider,
@@ -91,7 +144,7 @@ function ControlledStatusBar({
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null)
@@ -113,6 +166,11 @@ function ControlledStatusBar({
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const ProviderIcon = getProviderIcon(provider)
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
@@ -144,15 +202,39 @@ function ControlledStatusBar({
[thinkingOptions]
)
const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
const visuals = getModeVisuals(provider, option.id)
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
},
[provider, theme.colors.foreground]
)
const handleOpenChange = useCallback(
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
},
[]
)
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector)
},
[handleOpenChange, openSelector]
)
return (
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
<View style={styles.container}>
{isWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
@@ -161,7 +243,7 @@ function ControlledStatusBar({
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
onPress={() => handleSelectorPress('provider')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
@@ -190,24 +272,39 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<>
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
<Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress('mode')}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ''}
@@ -217,64 +314,90 @@ function ControlledStatusBar({
onOpenChange={handleOpenChange('mode')}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => setOpenSelector(openSelector === 'model' ? null : '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"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<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"
/>
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
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"
/>
</>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Pressable
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-thinking-selector"
<Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<Brain
size={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
style={{ marginTop: 1 }}
/>
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress('thinking')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={`Select thinking option (${displayThinking})`}
testID="agent-thinking-selector"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
@@ -300,7 +423,8 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text>
</Pressable>
<AdaptiveModalSheet
@@ -311,7 +435,10 @@ function ControlledStatusBar({
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
@@ -343,7 +470,10 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
@@ -355,17 +485,60 @@ function ControlledStatusBar({
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.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={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{mode.label}
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -373,39 +546,12 @@ function ControlledStatusBar({
</View>
) : null}
<View style={styles.sheetSection}>
<DropdownMenu>
<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>
</View>
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed }) => [
@@ -504,6 +650,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
return (
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
@@ -555,33 +702,26 @@ export function DraftAgentStatusBar({
selectedModel,
onSelectModel,
isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const providerOptions = useMemo<StatusOption[]>(() => {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}, [providerDefinitions])
const isWeb = Platform.OS === 'web'
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
}
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
const modelOptions = useMemo<StatusOption[]>(() => {
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
for (const model of models) {
options.push({ id: model.id, label: model.label })
}
return options
}, [models])
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
@@ -590,8 +730,45 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
if (isWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
isLoading={isAllModelsLoading}
disabled={disabled}
/>
<ControlledStatusBar
provider={selectedProvider}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
</View>
)
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
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)}
@@ -613,18 +790,26 @@ export function DraftAgentStatusBar({
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-end',
gap: theme.spacing[1],
},
modeBadge: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius['2xl'],
},
modeIconBadge: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -639,16 +824,28 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
prefsButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
height: 28,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
sheetSection: {
gap: theme.spacing[2],
},

View File

@@ -1,5 +1,18 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
case 'thinking':
return 'Thinking mode'
case 'model':
return 'Change model'
case 'mode':
return 'Change permission mode'
}
}
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {

View File

@@ -22,11 +22,10 @@ import Animated, {
FadeIn,
FadeOut,
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
@@ -68,8 +67,13 @@ import {
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -167,12 +171,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
const route = buildHostWorkspaceFileRoute(
resolvedServerId,
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
normalized.file
);
router.replace(route as any);
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
return;
}
@@ -257,6 +261,44 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[looseGap, tightGap]
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -659,50 +701,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
});
function WorkingIndicator() {
const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0);
const dotThree = useSharedValue(0);
const bounceDuration = 600;
const bounceDelayOffset = 160;
const progress = useSharedValue(0);
useEffect(() => {
const sharedValues = [dotOne, dotTwo, dotThree];
sharedValues.forEach((value, index) => {
value.value = withDelay(
index * bounceDelayOffset,
withRepeat(
withSequence(
withTiming(1, { duration: bounceDuration }),
withTiming(0, { duration: bounceDuration })
),
-1
)
);
});
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false
);
return () => {
sharedValues.forEach((value) => {
cancelAnimation(value);
value.value = 0;
});
cancelAnimation(progress);
progress.value = 0;
};
}, [dotOne, dotTwo, dotThree]);
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotOne.value * 0.7,
transform: [{ translateY: dotOne.value * translateDistance }],
}));
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(
progress.value,
WORKING_INDICATOR_OFFSETS[0]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotTwo.value * 0.7,
transform: [{ translateY: dotTwo.value * translateDistance }],
}));
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(
progress.value,
WORKING_INDICATOR_OFFSETS[1]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotThree.value * 0.7,
transform: [{ translateY: dotThree.value * translateDistance }],
}));
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(
progress.value,
WORKING_INDICATOR_OFFSETS[2]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
return (
<View style={stylesheet.workingIndicatorBubble}>

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import { View, Text } from 'react-native'
import { StyleSheet } from 'react-native-unistyles'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { Button } from '@/components/ui/button'
import type { Theme } from '@/styles/theme'
interface ArchivedAgentCalloutProps {
serverId: string
agentId: string
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const insets = useSafeAreaInsets()
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const [isUnarchiving, setIsUnarchiving] = useState(false)
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return
setIsUnarchiving(true)
try {
await client.refreshAgent(agentId)
} catch (error) {
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
setIsUnarchiving(false)
}
}
return (
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
<Text style={styles.calloutText}>This agent is archived</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
Unarchive
</Button>
</View>
</View>
</View>
</Animated.View>
)
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: 'column',
position: 'relative',
},
inputAreaContainer: {
position: 'relative',
minHeight: FOOTER_HEIGHT,
marginHorizontal: 'auto',
alignItems: 'center',
width: '100%',
overflow: 'visible',
padding: theme.spacing[4],
},
inputAreaContent: {
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
callout: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'],
paddingVertical: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
paddingHorizontal: {
xs: theme.spacing[4],
md: theme.spacing[6],
},
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as any) as Record<string, any>

View File

@@ -0,0 +1,372 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } 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 { getProviderIcon } from '@/components/provider-icons'
const INLINE_MODEL_THRESHOLD = 8
type DrillDownView = { provider: string }
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
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')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
},
[onSelect]
)
const ProviderIcon = getProviderIcon(selectedProvider)
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={() => handleOpenChange(!isOpen)}
style={({ pressed, hovered }) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
]}
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} />
</Pressable>
<Combobox
options={[]}
value=""
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
<GroupsView
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}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
}}
/>
)}
</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,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
sectionHeadingText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 36,
},
drillDownRowHovered: {
backgroundColor: theme.colors.surface1,
},
drillDownRowPressed: {
backgroundColor: theme.colors.surface2,
},
drillDownText: {
flex: 1,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
drillDownCount: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButtonHovered: {
backgroundColor: theme.colors.surface1,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
},
backButtonText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))

View File

@@ -7,7 +7,7 @@ import {
View,
Platform,
} from "react-native";
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -69,6 +69,9 @@ export function CommandCenter() {
const resultsRef = useRef<ScrollView>(null);
useEffect(() => {
if (!open) {
return;
}
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
@@ -99,18 +102,12 @@ export function CommandCenter() {
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
}, [activeIndex]);
}, [activeIndex, open]);
if (Platform.OS !== "web") return null;
if (Platform.OS !== "web" || !open) return null;
const actionItems = useMemo(
() => items.filter((item) => item.kind === "action"),
[items]
);
const agentItems = useMemo(
() => items.filter((item) => item.kind === "agent"),
[items]
);
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal

View File

@@ -479,7 +479,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
tabActive: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
tabText: {
fontSize: theme.fontSize.sm,

View File

@@ -23,6 +23,7 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import {
@@ -581,13 +582,12 @@ export function FileExplorerPane({
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
@@ -875,7 +875,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: 32 + theme.spacing[2] * 2,
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
@@ -898,7 +898,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
sortButton: {
height: 32,
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -1025,8 +1025,8 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
iconButton: {
width: 32,
height: 32,
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -1034,10 +1034,6 @@ const styles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonPressed: {
opacity: 0.8,
transform: [{ scale: 0.96 }],
},
refreshIcon: {
width: 16,
height: 16,

View File

@@ -25,6 +25,7 @@ import {
ListChevronsUpDown,
RefreshCcw,
Upload,
WrapText,
} from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
@@ -37,6 +38,7 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import {
@@ -46,6 +48,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
@@ -152,7 +155,7 @@ interface DiffFileSectionProps {
testID?: string;
}
function DiffLineView({ line }: { line: DiffLine }) {
function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
return (
<View
style={[
@@ -163,6 +166,15 @@ function DiffLineView({ line }: { line: DiffLine }) {
line.type === "context" && styles.contextLineContainer,
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText
tokens={line.tokens}
@@ -206,7 +218,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
<View
style={[
styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder,
isExpanded && styles.fileSectionHeaderExpanded,
]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
@@ -279,10 +291,12 @@ const DiffFileHeader = memo(function DiffFileHeader({
function DiffFileBody({
file,
wrapLines,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
@@ -331,39 +345,86 @@ function DiffFileBody({
}}
testID={testID}
>
{file.status === "too_large" || file.status === "binary" ? (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
) : (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
{file.hunks.map((hunk, hunkIndex) =>
hunk.lines.map((line, lineIndex) => (
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} />
))
)}
</View>
</ScrollView>
)}
{(() => {
if (file.status === "too_large" || file.status === "binary") {
return (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
);
}
const linesContent = (() => {
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
}
const digitCount = Math.max(1, String(maxLineNo).length);
const gutterWidth = digitCount * 8 + 12;
return file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
return hunk.lines.map((line, lineIndex) => {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
return (
<DiffLineView
key={`${hunkIndex}-${lineIndex}`}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
/>
);
});
});
})();
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{linesContent}
</View>
</View>
);
}
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
{linesContent}
</View>
</ScrollView>
);
})()}
</View>
);
}
@@ -389,6 +450,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [wrapLines, setWrapLines] = useState(false);
useEffect(() => {
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
if (value === "true") setWrapLines(true);
});
}, []);
const handleToggleWrapLines = useCallback(() => {
setWrapLines((prev) => {
const next = !prev;
AsyncStorage.setItem("diff-wrap-lines", String(next));
return next;
});
}, []);
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
@@ -735,12 +812,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return (
<DiffFileBody
file={item.file}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded]
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
);
const flatKeyExtractor = useCallback(
@@ -1017,19 +1095,48 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</DropdownMenuContent>
</DropdownMenu>
{files.length > 0 ? (
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={14} color={theme.colors.foregroundMuted} />
) : (
<ListChevronsUpDown size={14} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
</View>
</View>
@@ -1086,11 +1193,12 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
diffStatusContainer: {
paddingVertical: 1.5,
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
diffStatusInner: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
@@ -1123,13 +1231,30 @@ const styles = StyleSheet.create((theme) => ({
diffStatusIconHidden: {
opacity: 0,
},
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
sm: theme.spacing[1],
md: 0,
},
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
paddingVertical: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
},
actionErrorText: {
@@ -1190,6 +1315,8 @@ const styles = StyleSheet.create((theme) => ({
},
fileSectionHeaderContainer: {
overflow: "hidden",
},
fileSectionHeaderExpanded: {
backgroundColor: theme.colors.surface1,
},
fileSectionBodyContainer: {
@@ -1208,7 +1335,6 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
zIndex: 2,
elevation: 2,
},
@@ -1286,10 +1412,34 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1,
},
diffLineContainer: {
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "stretch",
},
lineNumberGutter: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
marginRight: theme.spacing[2],
alignSelf: "stretch",
justifyContent: "center",
},
lineNumberText: {
textAlign: "right",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
},
addLineNumberText: {
color: theme.colors.palette.green[400],
},
removeLineNumberText: {
color: theme.colors.palette.red[500],
},
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
@@ -1330,4 +1480,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
}));

View File

@@ -6,9 +6,9 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsTauriMac,
getIsDesktopMac,
} from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps {
@@ -37,18 +37,20 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsTauriMac()
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
// On Tauri macOS, enable window dragging and double-click to maximize
const dragHandlers = useTauriDragHandlers();
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
]}
{...dragHandlers}
>
<View style={[styles.left, leftStyle]}>{left}</View>

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri();
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
[isMac, isTauri]
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
);
return (

View File

@@ -1,4 +1,15 @@
import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
import {
memo,
useCallback,
useMemo,
useState,
useEffect,
useRef,
useSyncExternalStore,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Animated, {
@@ -11,14 +22,16 @@ import Animated, {
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
@@ -29,15 +42,61 @@ import {
mapPathnameToServer,
parseServerIdFromPathname,
} from '@/utils/host-routes'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
interface LeftSidebarProps {
selectedAgentId?: string
}
export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
interface HostOption {
id: string
label: string
description: string
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
@@ -107,33 +166,18 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View>(null)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false)
const handleRefresh = useCallback(() => {
@@ -141,29 +185,23 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
refreshAll()
}, [refreshAll])
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
}
}, [isRevalidating, isManualRefresh])
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
const openProjectPicker = useOpenProjectPicker(activeServerId)
const handleOpenProjectMobile = useCallback(() => {
closeToAgent()
setProjectPickerOpen(true)
}, [closeToAgent, setProjectPickerOpen])
void openProjectPicker()
}, [closeToAgent, openProjectPicker])
const handleOpenProjectDesktop = useCallback(() => {
setProjectPickerOpen(true)
}, [setProjectPickerOpen])
void openProjectPicker()
}, [openProjectPicker])
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
@@ -172,7 +210,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
// Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
@@ -180,17 +217,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
const handleViewMore = useCallback(() => {
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
}
if (isMobile) {
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
}
router.push(buildHostAgentsRoute(activeServerId) as any)
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
}, [activeServerId])
const handleHostSelect = useCallback(
(nextServerId: string) => {
@@ -204,77 +236,201 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
[pathname]
)
// Close gesture (swipe left to close when sidebar is open)
const closeGesture = Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
// Use manual activation so child views keep touch streams unless we detect
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const sharedProps = {
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
if (isMobile) {
return (
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={closeToAgent}
handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
}
// Fail quickly on clear rightward or vertical intent so child views keep control.
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
return (
<DesktopSidebar
{...sharedProps}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => {
isGesturing.value = true
})
.onUpdate((event) => {
if (!isMobile) return
// Only allow swiping left (closing)
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
})
.onEnd((event) => {
isGesturing.value = false
if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
} else {
animateToOpen()
}
})
.onFinalize(() => {
isGesturing.value = false
})
function MobileSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
handleSettings,
insetsTop,
insetsBottom,
isOpen,
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
}, [
activeServerId,
backdropOpacity,
closeToAgent,
handleViewMoreNavigate,
translateX,
windowWidth,
])
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => {
isGesturing.value = true
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
} else {
animateToOpen()
}
})
.onFinalize(() => {
isGesturing.value = false
}),
[
isOpen,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
isGesturing,
windowWidth,
translateX,
backdropOpacity,
animateToClose,
animateToOpen,
handleClose,
]
)
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
@@ -285,149 +441,177 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
// Render mobile sidebar
// On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed.
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
sidebarAnimatedStyle,
]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleOpenProjectMobile}
>
{({ hovered }) => (
<>
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
hovered && styles.newAgentButtonTextHovered,
]}
>
Add project
</Text>
</>
)}
</Pressable>
</View>
</View>
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="Sessions"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
// Desktop: no edge swipe, just show/hide based on isOpen
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
function DesktopSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
handleSettings,
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
if (!isOpen) {
return null
}
@@ -441,19 +625,22 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleOpenProjectDesktop}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Plus
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Add project
Sessions
</Text>
</>
)}
@@ -461,7 +648,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</View>
</View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
@@ -469,6 +655,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -476,7 +663,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
@@ -488,30 +674,40 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="Sessions"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<MessagesSquare
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -520,7 +716,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsDesktop}
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
@@ -594,7 +790,8 @@ const styles = StyleSheet.create((theme) => ({
alignItems: 'center',
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
paddingRight: theme.spacing[1],
paddingLeft: theme.spacing[3],
flexShrink: 0,
},
newAgentButtonHovered: {},
@@ -615,12 +812,9 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
hostTriggerHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
hostStatusDot: {
width: 8,
@@ -690,4 +884,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}))

View File

@@ -212,6 +212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref
) {
const { theme } = useUnistyles()
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
@@ -944,9 +945,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -972,18 +977,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
? 'Stop dictation'
: 'Start dictation'
}
style={[
style={({ hovered }) => [
styles.voiceButton,
hovered && !isDictating && styles.iconButtonHovered,
(!isDictationStartEnabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={theme.iconSize.lg} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
<MicOff size={buttonIconSize} color={theme.colors.foreground} />
) : (
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
<Mic size={buttonIconSize} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -1010,9 +1016,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
style={({ hovered }) => [
styles.queueButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={theme.iconSize.lg} color="white" />
<Plus size={buttonIconSize} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -1034,7 +1044,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={theme.iconSize.lg} color="white" />
<ArrowUp size={buttonIconSize} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -1156,9 +1166,9 @@ const styles = StyleSheet.create(((theme: any) => ({
textInput: {
width: '100%',
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.lg * 1.4,
lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB
? {
outlineStyle: 'none' as const,
@@ -1174,24 +1184,24 @@ const styles = StyleSheet.create(((theme: any) => ({
},
leftButtonGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
alignItems: 'flex-end',
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
},
rightButtonGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
},
attachButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
voiceButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
@@ -1200,21 +1210,24 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.destructive,
},
queueButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: 'center',
justifyContent: 'center',
},
sendButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -71,6 +71,10 @@ import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
} from "@/utils/tool-call-detail-state";
import {
parseAssistantFileLink,
parseInlinePathToken,
@@ -537,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
alignSelf: "flex-start",
padding: theme.spacing[2],
paddingTop: 0,
marginTop: theme.spacing[2],
},
iconColor: {
color: theme.colors.foregroundMuted,
@@ -620,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginHorizontal: -6,
},
containerSpacing: {
marginBottom: theme.spacing[1],
@@ -631,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
pressable: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
borderColor: "transparent",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
overflow: "hidden",
@@ -656,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
marginRight: theme.spacing[2],
marginRight: theme.spacing[1],
backgroundColor: "transparent",
},
label: {
color: theme.colors.foreground,
opacity: 0.88,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
labelActive: {
color: theme.colors.foreground,
},
labelLoading: {
color: theme.colors.foreground,
opacity: 0.72,
@@ -677,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
marginLeft: theme.spacing[2],
},
secondaryLabelActive: {
color: theme.colors.foreground,
},
shimmerText: {
color: "transparent",
fontSize: theme.fontSize.base,
@@ -706,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
overflow: "hidden",
},
pressableExpanded: {
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
@@ -748,11 +759,18 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme } = useUnistyles();
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo(
() => {
@@ -937,6 +955,22 @@ export const AssistantMessage = memo(function AssistantMessage({
</View>
);
},
paragraph: (
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
{children}
</View>
);
},
link: (
node: any,
children: ReactNode[],
@@ -1590,12 +1624,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded, isInteractive]
);
const isActive = isHovered || isExpanded;
const labelStyle = useMemo(
() => [
expandableBadgeStylesheet.label,
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading,
],
[isLoading]
[isActive, isLoading]
);
const secondaryLabelStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
);
const shimmerLabelTextStyle = useMemo(
@@ -1666,7 +1711,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
const IconComponent = icon;
const iconColor = isError
? theme.colors.destructive
: theme.colors.mutedForeground;
: isActive
? theme.colors.foreground
: theme.colors.mutedForeground;
let iconNode: ReactNode = null;
if (isError) {
@@ -1713,7 +1760,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Text>
{secondaryLabel ? (
<Text
style={expandableBadgeStylesheet.secondaryLabel}
style={secondaryLabelStyle}
numberOfLines={1}
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
>
@@ -1808,7 +1855,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
{isInteractive && isHovered ? (
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
color={theme.colors.foreground}
style={chevronStyle}
/>
) : null}
@@ -1878,6 +1925,9 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1923,31 +1973,42 @@ export const ToolCall = memo(function ToolCall({
const summary = displayModel.summary;
const errorText = displayModel.errorText;
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
const isLoadingDetails = isPendingToolCallDetail({
detail: effectiveDetail,
status,
error,
});
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails =
Boolean(error) ||
(effectiveDetail
? effectiveDetail.type === "plain_text"
? Boolean(effectiveDetail.text)
: effectiveDetail.type !== "unknown" ||
effectiveDetail.input !== null ||
effectiveDetail.output !== null
: false);
hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => {
if (isMobile) {
openToolCall({
toolName,
displayName,
summary,
summary: secondaryLabel,
detail: effectiveDetail,
errorText,
showLoadingSkeleton: isLoadingDetails,
});
} else {
setIsExpanded((prev) => !prev);
}
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
}, [
isMobile,
openToolCall,
toolName,
displayName,
secondaryLabel,
effectiveDetail,
errorText,
isLoadingDetails,
]);
useEffect(() => {
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
@@ -1984,19 +2045,20 @@ export const ToolCall = memo(function ToolCall({
detail={effectiveDetail}
errorText={errorText}
maxHeight={400}
showLoadingSkeleton={isLoadingDetails}
/>
);
}, [isMobile, effectiveDetail, errorText]);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
return (
<ExpandableBadge
testID="tool-call-badge"
label={displayName}
secondaryLabel={summary}
secondaryLabel={secondaryLabel}
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={hasDetails ? handleToggle : undefined}
renderDetails={hasDetails && !isMobile ? renderDetails : undefined}
onToggle={canOpenDetails ? handleToggle : undefined}
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}

View File

@@ -11,21 +11,17 @@ import {
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { router, usePathname } from "expo-router";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const toast = useToast();
const pathname = usePathname();
const daemons = useHosts();
@@ -38,19 +34,17 @@ export function ProjectPickerModal() {
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore(
(state) => state.setHasHydratedWorkspaces
);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId);
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
@@ -101,31 +95,15 @@ export function ProjectPickerModal() {
setIsSubmitting(true);
try {
const payload = await client.openProject(trimmed);
if (payload.error || !payload.workspace) {
throw new Error(payload.error || "Failed to open project");
const didOpenProject = await openProject(trimmed);
if (didOpenProject) {
setOpen(false);
}
mergeWorkspaces(serverId, [
normalizeWorkspaceDescriptor(payload.workspace),
]);
setHasHydratedWorkspaces(serverId, true);
setOpen(false);
router.replace(
buildHostWorkspaceRouteWithOpenIntent(
serverId,
payload.workspace.id,
{ kind: "draft", draftId: "new" }
) as any
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to open project"
);
} finally {
setIsSubmitting(false);
}
},
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast]
[client, openProject, serverId, setOpen]
);
const handleSubmitCustom = useCallback(() => {
@@ -294,7 +272,7 @@ export function ProjectPickerModal() {
]}
numberOfLines={1}
>
{path}
{shortenPath(path)}
</Text>
</View>
</Pressable>

View File

@@ -0,0 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
}

View File

@@ -0,0 +1,190 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
groupId: string;
index: number;
sizes: number[];
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
export function ResizeHandle({
direction,
groupId,
index,
sizes,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false);
const highlighted = active || dragging;
const handlePointerDown = useCallback(
(event: any) => {
const hitAreaElement = event.currentTarget as HTMLElement | null;
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
setDragging(true);
pointerStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
const previousCursor = document.body.style.cursor;
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
function cleanup() {
pointerStateRef.current = null;
setDragging(false);
document.body.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (!pointerState) {
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
}
function handlePointerUp() {
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
);
return (
<View
style={[
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
{ backgroundColor: theme.colors.border },
]}
>
{highlighted && (
<View
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}}
onPointerLeave={() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}}
/>
</View>
);
}
const styles = StyleSheet.create((_theme) => ({
handle: {
position: "relative",
flexShrink: 0,
},
handleHorizontal: {
width: 1,
alignSelf: "stretch",
},
handleVertical: {
height: 1,
width: "100%",
},
highlight: {
position: "absolute",
zIndex: 5,
},
highlightHorizontal: {
top: 0,
bottom: 0,
width: 3,
left: -1,
},
highlightVertical: {
left: 0,
right: 0,
height: 3,
top: -1,
},
hitArea: {
position: "absolute",
zIndex: 10,
},
hitAreaHorizontal: {
left: -5,
top: 0,
bottom: 0,
width: 10,
},
hitAreaVertical: {
top: -5,
left: 0,
right: 0,
height: 10,
},
}));

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
onDragEnd?: (data: T[]) => void;
useDragHandle?: boolean;
disabled?: boolean;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
return (
<>

View File

@@ -33,6 +33,8 @@ function SortableItem<T>({
activeId,
useDragHandle,
disabled,
itemData,
externalDndContext,
}: {
id: string;
item: T;
@@ -41,6 +43,8 @@ function SortableItem<T>({
activeId: string | null;
useDragHandle: boolean;
disabled: boolean;
itemData?: Record<string, unknown>;
externalDndContext: boolean;
}): ReactElement {
const {
attributes,
@@ -50,24 +54,27 @@ function SortableItem<T>({
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
} = useSortable({ id, disabled, data: itemData });
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
// This is a no-op but matches the mobile API
}, []);
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
);
const scaleTransform = isDragging ? "scale(1.01)" : "";
// External DnD contexts render their own insertion affordance, so keep the
// tab row static and let the DragOverlay carry the moving chip.
const baseTransform = externalDndContext
? undefined
: CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
);
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = {
transform: combinedTransform || undefined,
transition,
opacity: isDragging ? 0.9 : 1,
opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
};
@@ -107,6 +114,9 @@ export function SortableInlineList<T>({
disabled = false,
activationDistance = 8,
onDragBegin,
externalDndContext = false,
activeId: externalActiveId = null,
getItemData,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
@@ -116,10 +126,13 @@ export function SortableInlineList<T>({
disabled?: boolean;
activationDistance?: number;
onDragBegin?: () => void;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const items = externalDndContext ? data : dragItems ?? data;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -175,6 +188,32 @@ export function SortableInlineList<T>({
const ids = items.map((item, index) => keyExtractor(item, index));
const renderedItems = (
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={externalDndContext ? externalActiveId : activeId}
useDragHandle={useDragHandle}
disabled={disabled}
itemData={getItemData?.(item, index)}
externalDndContext={externalDndContext}
/>
);
})}
</SortableContext>
);
if (externalDndContext) {
return renderedItems;
}
return (
<DndContext
sensors={sensors}
@@ -183,23 +222,7 @@ export function SortableInlineList<T>({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
disabled={disabled}
/>
);
})}
</SortableContext>
{renderedItems}
</DndContext>
);
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
describe("shouldFocusPaneFromEventTarget", () => {
it("returns false for links and buttons", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "A" } as Element),
} as unknown as EventTarget)
).toBe(false);
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "BUTTON" } as Element),
} as unknown as EventTarget)
).toBe(false);
});
it("returns true for non-interactive pane content", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => null,
} as unknown as EventTarget)
).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
const INTERACTIVE_TARGET_SELECTOR = [
"a",
"button",
"input",
"select",
"textarea",
"[role='button']",
"[role='link']",
"[contenteditable='true']",
"[data-paseo-pane-focus-exempt='true']",
].join(", ");
export function shouldFocusPaneFromEventTarget(target: EventTarget | null): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return true;
}
return !candidate.closest(INTERACTIVE_TARGET_SELECTOR);
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { computeTabDropPreview } from "@/components/split-container-tab-drop-preview";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
function tab(tabId: string): WorkspaceTabDescriptor {
return {
key: tabId,
tabId,
kind: "draft",
target: {
kind: "draft",
draftId: tabId,
},
};
}
describe("computeTabDropPreview", () => {
const targetTabs = [tab("a"), tab("b"), tab("c"), tab("d")];
it("returns a before-target insertion index for cross-pane drops on the left half", () => {
expect(
computeTabDropPreview({
activePaneId: "source",
activeTabId: "x",
overPaneId: "target",
overTabId: "c",
targetTabs,
activeRect: { left: 180, width: 40 },
overRect: { left: 200, width: 100 },
})
).toEqual({
paneId: "target",
insertionIndex: 2,
indicatorIndex: 2,
});
});
it("returns an after-target insertion index for cross-pane drops on the right half", () => {
expect(
computeTabDropPreview({
activePaneId: "source",
activeTabId: "x",
overPaneId: "target",
overTabId: "c",
targetTabs,
activeRect: { left: 280, width: 40 },
overRect: { left: 200, width: 100 },
})
).toEqual({
paneId: "target",
insertionIndex: 3,
indicatorIndex: 3,
});
});
it("adjusts same-pane drops so insertion indexes match arrayMove semantics", () => {
expect(
computeTabDropPreview({
activePaneId: "pane",
activeTabId: "b",
overPaneId: "pane",
overTabId: "d",
targetTabs,
activeRect: { left: 460, width: 40 },
overRect: { left: 400, width: 100 },
})
).toEqual({
paneId: "pane",
insertionIndex: 3,
indicatorIndex: 4,
});
});
});

View File

@@ -0,0 +1,55 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface TabDropPreview {
paneId: string;
insertionIndex: number;
indicatorIndex: number;
}
interface ComputeTabDropPreviewInput {
activePaneId: string;
activeTabId: string;
overPaneId: string;
overTabId: string;
targetTabs: WorkspaceTabDescriptor[];
activeRect: {
left: number;
width: number;
};
overRect: {
left: number;
width: number;
};
}
export function computeTabDropPreview(
input: ComputeTabDropPreviewInput
): TabDropPreview | null {
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
if (targetIndex < 0 || input.overRect.width <= 0) {
return null;
}
const activeCenterX = input.activeRect.left + input.activeRect.width / 2;
const overCenterX = input.overRect.left + input.overRect.width / 2;
const insertAfterTarget = activeCenterX >= overCenterX;
const indicatorIndex = targetIndex + (insertAfterTarget ? 1 : 0);
let insertionIndex = indicatorIndex;
if (input.activePaneId === input.overPaneId) {
const sourceIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.activeTabId);
if (sourceIndex < 0) {
return null;
}
if (sourceIndex < insertionIndex) {
insertionIndex -= 1;
}
insertionIndex = Math.max(0, Math.min(input.targetTabs.length - 1, insertionIndex));
}
return {
paneId: input.overPaneId,
insertionIndex,
indicatorIndex,
};
}

View File

@@ -0,0 +1,891 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
computeTabDropPreview,
type TabDropPreview,
} from "@/components/split-container-tab-drop-preview";
import {
SplitDropZone,
resolveSplitDropPosition,
type SplitDropZoneHover,
} from "@/components/split-drop-zone";
import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
closingTabIds: Set<string>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__" | "__new_tab_terminal__"; paneId?: string }) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void;
onSplitPane: (input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onSplitPaneEmpty: (input: {
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
}
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
interface SplitPaneViewProps
extends Omit<
SplitNodeViewProps,
| "node"
| "workspaceKey"
| "focusedPaneId"
| "activeDragTabId"
| "showDropZones"
| "dropPreview"
| "onMoveTabToPane"
| "onResizeSplit"
> {
pane: SplitPane;
uiTabs: WorkspaceTab[];
isFocused: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
export function SplitContainer({
layout,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__",
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
}, []);
const updateDropPreview = useCallback(
(
event:
| Pick<DragMoveEvent, "active" | "over">
| Pick<DragOverEvent, "active" | "over">
) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
if (activeData?.kind !== "workspace-tab") {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const targetTabs = getWorkspacePaneDescriptors({
pane: targetPane,
tabs: uiTabs,
});
setDropPreview(null);
setTabDropPreview(
computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: translatedRect.left,
width: translatedRect.width,
},
overRect: {
left: overRect.left,
width: overRect.width,
},
})
);
return;
}
setTabDropPreview(null);
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
return;
}
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
const relativeX = centerX - overRect.left;
const relativeY = centerY - overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > overRect.width ||
relativeY < 0 ||
relativeY > overRect.height
) {
setDropPreview(null);
return;
}
setDropPreview({
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: overRect.width,
height: overRect.height,
x: relativeX,
y: relativeY,
}),
});
},
[panesById, uiTabs]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
setActiveDragTabId(null);
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
}
setDropPreview(null);
setTabDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={layout.root}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function DragOverlayTabChip({
tabId,
uiTabs,
normalizedServerId,
normalizedWorkspaceId,
}: {
tabId: string;
uiTabs: WorkspaceTab[];
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const tab = uiTabs.find((t) => t.tabId === tabId);
if (!tab) {
return null;
}
const descriptor: WorkspaceTabDescriptor = {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
};
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function DragOverlayTabChipInner({
tab,
normalizedServerId,
normalizedWorkspaceId,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { theme } = useUnistyles();
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? "Loading..." : presentation.label;
return (
<View
style={[
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
]}
>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
>
{label}
</Text>
</View>
);
}}
</WorkspaceTabPresentationResolver>
);
}
function SplitNodeView({
node,
workspaceKey,
uiTabs,
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitNodeViewProps) {
if (node.kind === "pane") {
return (
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
);
}
const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
node.group.sizes;
return (
<View
style={[
styles.group,
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
]}
>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
</View>
{index < node.group.children.length - 1 ? (
<ResizeHandle
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={groupSizes}
onResizeSplit={onResizeSplit}
/>
) : null}
</Fragment>
))}
</View>
);
}
function SplitPaneView({
pane,
uiTabs,
isFocused,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
pane,
tabs: uiTabs,
}),
[pane, uiTabs]
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => ({
tab,
isActive: tab.key === activeTabDescriptor?.key,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[
activeTabDescriptor?.key,
closingTabIds,
hoveredCloseTabKey,
paneTabs,
]
);
const paneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
paneId: pane.id,
isPaneFocused: isFocused,
tab: activeTabDescriptor,
})
: null,
[
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const paneElement = paneRef.current as unknown as HTMLElement | null;
if (
!paneElement ||
typeof paneElement.addEventListener !== "function" ||
typeof paneElement.removeEventListener !== "function"
) {
return;
}
const handlePanePointerDown = (event: PointerEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
const handlePaneFocusIn = (event: FocusEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
return () => {
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
};
}, [onFocusPane, pane.id]);
return (
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
}}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
/>
</View>
<View style={styles.paneContent}>
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
renderPaneEmptyState?.() ?? null
)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
);
}
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
function visit(current: SplitNode) {
if (current.kind === "pane") {
next.set(current.pane.id, current.pane);
return;
}
for (const child of current.group.children) {
visit(child);
}
}
visit(node);
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
}
return node.group.id;
}
const styles = StyleSheet.create((theme) => ({
group: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
groupHorizontal: {
flexDirection: "row",
},
groupVertical: {
flexDirection: "column",
},
groupChild: {
flexBasis: 0,
minWidth: 0,
minHeight: 0,
},
pane: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
paneTabs: {
minWidth: 0,
},
paneContent: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
},
dragOverlayChip: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
maxWidth: 200,
},
dragOverlayLabel: {
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
}));

View File

@@ -0,0 +1,208 @@
import { useMemo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
export interface SplitDropZoneHover {
paneId: string;
position: SplitDropZonePosition;
}
export interface SplitDropZoneProps {
paneId: string;
active: boolean;
preview: SplitDropZoneHover | null;
}
const EDGE_RATIO = 0.15;
const CENTER_RATIO = 0.4;
export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
}: SplitDropZoneProps) {
const { theme } = useUnistyles();
const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId),
disabled: !active,
data: {
kind: "split-pane-drop",
paneId,
},
});
const previewStyles = useMemo(() => {
if (!preview || preview.paneId !== paneId) {
return null;
}
return {
overlay: [
styles.previewOverlay,
getPreviewOverlayStyle(preview.position),
{
backgroundColor: theme.colors.accent,
opacity: 0.6,
},
],
frame: [
styles.previewFrame,
getPreviewFrameStyle(preview.position),
{
borderColor: theme.colors.accent,
},
],
};
}, [paneId, preview, theme.colors.accent]);
if (!active) {
return null;
}
return (
<View
ref={setNodeRef as any}
style={styles.overlay}
pointerEvents="none"
>
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
<View pointerEvents="none" style={previewStyles.frame} />
</>
) : null}
</View>
);
}
export function resolveSplitDropPosition(input: {
width: number;
height: number;
x: number;
y: number;
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";
}
const edgeThresholdX = input.width * EDGE_RATIO;
const edgeThresholdY = input.height * EDGE_RATIO;
if (input.x <= edgeThresholdX) {
return "left";
}
if (input.x >= input.width - edgeThresholdX) {
return "right";
}
if (input.y <= edgeThresholdY) {
return "top";
}
if (input.y >= input.height - edgeThresholdY) {
return "bottom";
}
const distances = [
{ position: "left", distance: input.x },
{ position: "right", distance: input.width - input.x },
{ position: "top", distance: input.y },
{ position: "bottom", distance: input.height - input.y },
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
distances.sort((left, right) => left.distance - right.distance);
return distances[0]?.position ?? "center";
}
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenterOverlay;
}
function getPreviewFrameStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenterFrame;
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 40,
},
previewOverlay: {
position: "absolute",
borderRadius: theme.borderRadius.md,
},
previewFrame: {
position: "absolute",
borderRadius: theme.borderRadius.md,
borderWidth: 2,
},
previewLeft: {
left: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewRight: {
right: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewTop: {
left: 0,
top: 0,
right: 0,
height: "50%",
},
previewBottom: {
left: 0,
right: 0,
bottom: 0,
height: "50%",
},
previewCenterOverlay: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
previewCenterFrame: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
}));

View File

@@ -1,6 +1,6 @@
"use dom";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
@@ -8,9 +8,29 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { focusWithRetries } from "../utils/web-focus";
import {
summarizeTerminalText,
terminalDebugLog,
} from "../terminal/runtime/terminal-debug";
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
type ViewportMetrics = {
offset: number;
viewportSize: number;
contentSize: number;
};
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
@@ -74,6 +94,34 @@ declare global {
interface Window {}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function ensureTerminalScrollbarStyle(): void {
if (typeof document === "undefined") {
return;
}
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
return;
}
const styleElement = document.createElement("style");
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
styleElement.textContent = `
[data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
document.head.appendChild(styleElement);
}
export default function TerminalEmulator({
streamKey,
initialOutputText,
@@ -104,13 +152,32 @@ export default function TerminalEmulator({
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedChunkSequenceRef = useRef(0);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
useEffect(() => {
mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme });
}, [themeKey]);
useEffect(() => {
ensureTerminalScrollbarStyle();
}, []);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -287,14 +354,6 @@ export default function TerminalEmulator({
}
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:skip-duplicate",
details: {
sequence: outputChunkSequence,
lastAppliedSequence: appliedChunkSequenceRef.current,
},
});
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
@@ -307,13 +366,6 @@ export default function TerminalEmulator({
appliedChunkSequenceRef.current = outputChunkSequence;
if (outputChunkText.length === 0) {
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:clear",
details: {
sequence: outputChunkSequence,
},
});
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
@@ -321,16 +373,6 @@ export default function TerminalEmulator({
});
return;
}
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:write",
details: {
sequence: outputChunkSequence,
replay: outputChunkReplay,
length: outputChunkText.length,
preview: summarizeTerminalText({ text: outputChunkText, maxChars: 80 }),
},
});
runtime.write({
text: outputChunkText,
suppressInput: outputChunkReplay,
@@ -366,10 +408,206 @@ export default function TerminalEmulator({
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return;
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) {
viewportRef.current = null;
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
return;
}
viewportRef.current = viewportElement;
const updateViewportMetrics = () => {
setViewportMetrics({
offset: Math.max(0, viewportElement.scrollTop),
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
updateViewportMetrics();
const handleViewportScroll = () => {
updateViewportMetrics();
};
const resizeObserver = new ResizeObserver(() => {
updateViewportMetrics();
});
resizeObserver.observe(viewportElement);
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
if (scrollAreaElement) {
resizeObserver.observe(scrollAreaElement);
}
const mutationObserver = new MutationObserver(() => {
updateViewportMetrics();
});
mutationObserver.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
return () => {
viewportElement.removeEventListener("scroll", handleViewportScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (viewportRef.current === viewportElement) {
viewportRef.current = null;
}
};
}, [streamKey]);
useEffect(() => {
const maxScrollOffset = Math.max(
0,
viewportMetrics.contentSize - viewportMetrics.viewportSize
);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false);
setIsScrollActive(false);
lastObservedOffsetRef.current = null;
return;
}
const previousOffset = lastObservedOffsetRef.current;
lastObservedOffsetRef.current = normalizedOffset;
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
return;
}
setIsScrollVisible(true);
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
scrollVisibilityTimeoutRef.current = setTimeout(() => {
setIsScrollVisible(false);
scrollVisibilityTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
setIsScrollActive(true);
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [
viewportMetrics.contentSize,
viewportMetrics.offset,
viewportMetrics.viewportSize,
]);
useEffect(() => {
return () => {
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
};
}, []);
const scrollbarGeometry = useMemo(
() =>
computeVerticalScrollbarGeometry({
viewportSize: viewportMetrics.viewportSize,
contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset,
}),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
);
useEffect(() => {
if (!isDraggingScrollbar) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragDelta = event.clientY - dragStartClientYRef.current;
const nextOffset = computeScrollOffsetFromDragDelta({
startOffset: dragStartOffsetRef.current,
dragDelta,
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
});
const viewportElement = viewportRef.current;
if (!viewportElement) {
return;
}
viewportElement.scrollTop = nextOffset;
setViewportMetrics({
offset: nextOffset,
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
const stopDragging = () => {
setIsDraggingScrollbar(false);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
const handleOpacity = isDraggingScrollbar
? SCROLLBAR_HANDLE_OPACITY_DRAGGING
: isHandleHovered
? SCROLLBAR_HANDLE_OPACITY_HOVERED
: isScrollVisible
? SCROLLBAR_HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth =
isDraggingScrollbar || isHandleHovered
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
: SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max(
0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
);
const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
);
const handleInsetTop = Math.max(
0,
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
return (
<div
ref={rootRef}
data-testid={testId}
data-terminal-scrollbar-root="true"
style={{
position: "relative",
display: "flex",
@@ -383,10 +621,6 @@ export default function TerminalEmulator({
touchAction: "pan-y",
}}
onPointerDown={() => {
terminalDebugLog({
scope: "emulator-component",
event: "surface:pointer-down-focus",
});
runtimeRef.current?.focus();
}}
>
@@ -400,8 +634,82 @@ export default function TerminalEmulator({
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 8,
paddingRight: 0,
}}
/>
{scrollbarGeometry.isVisible ? (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
pointerEvents: "none",
}}
>
<div
style={{
position: "absolute",
top: 0,
right: -3,
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
height: thumbRegionHeight,
transform: `translateY(${thumbRegionOffset}px)`,
cursor: isDraggingScrollbar ? "grabbing" : "grab",
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
}}
onPointerEnter={() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}}
onPointerLeave={() => {
setIsHandleHovered(false);
}}
>
<div
style={{
marginTop: handleInsetTop,
height: scrollbarGeometry.handleSize,
width: handleWidth,
borderRadius: 999,
alignSelf: "center",
backgroundColor: "rgba(113, 113, 122, 1)",
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction:
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}}
/>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -19,7 +19,7 @@ import Svg, {
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import {
hasPendingTerminalModifiers,
@@ -35,10 +35,6 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import {
summarizeTerminalText,
terminalDebugLog,
} from "@/terminal/runtime/terminal-debug";
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator from "./terminal-emulator";
@@ -154,7 +150,8 @@ export function TerminalPane({
});
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
@@ -366,7 +363,7 @@ export function TerminalPane({
const terminals = terminalsQuery.data?.terminals ?? [];
useEffect(() => {
if (!client || !isConnected) {
if (!client || !isConnected || !isScreenFocused) {
return;
}
@@ -716,17 +713,6 @@ export function TerminalPane({
meta: input.meta,
},
};
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:send",
details: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId: getCurrentActiveStreamId(),
},
});
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
@@ -745,16 +731,6 @@ export function TerminalPane({
if (data.length === 0) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:received",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId: currentStreamId,
},
});
if (hasPendingTerminalModifiers(modifiers)) {
const pendingResolution = resolvePendingModifierDataInput({
@@ -788,15 +764,6 @@ export function TerminalPane({
});
return;
}
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:send",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId: currentStreamId,
},
});
const pendingEntry: PendingTerminalInput = {
type: "data",
data,
@@ -835,15 +802,6 @@ export function TerminalPane({
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
terminalDebugLog({
scope: "terminal-pane",
event: "display:resize:send",
details: {
terminalId: selectedTerminalId,
rows: normalizedRows,
cols: normalizedCols,
},
});
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
@@ -871,13 +829,6 @@ export function TerminalPane({
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
terminalDebugLog({
scope: "terminal-pane",
event: "output:chunk:consumed",
details: {
sequence,
},
});
outputSession.consume({ sequence });
}, [outputSession]);
@@ -1040,7 +991,7 @@ export function TerminalPane({
) : null}
<View style={styles.outputContainer}>
{selectedTerminal ? (
{selectedTerminal && isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
dom={{
@@ -1084,13 +1035,15 @@ export function TerminalPane({
resizeRequestToken={resizeRequestToken}
/>
</View>
) : selectedTerminal ? (
<View style={styles.terminalGestureContainer} />
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching ? (
{isAttaching && isScreenFocused ? (
<View
style={styles.attachOverlay}
pointerEvents="none"

View File

@@ -0,0 +1,328 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
Animated,
Easing,
Platform,
Text,
ToastAndroid,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
export type ToastVariant = "default" | "success" | "error";
export type ToastShowOptions = {
icon?: ReactNode;
variant?: ToastVariant;
durationMs?: number;
nativeAndroid?: boolean;
testID?: string;
};
export type ToastState = {
id: number;
content: ReactNode;
nativeMessage: string | null;
icon?: ReactNode;
variant: ToastVariant;
durationMs: number;
testID?: string;
};
export type ToastApi = {
show: (content: ReactNode, options?: ToastShowOptions) => void;
copied: (label?: string) => void;
error: (message: string) => void;
};
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
export function useToastHost(): {
api: ToastApi;
toast: ToastState | null;
dismiss: () => void;
} {
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback(
(content: ReactNode, options?: ToastShowOptions) => {
const nativeMessage =
typeof content === "string"
? content.trim()
: null;
if (!content || nativeMessage === "") {
return;
}
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs <= 2500
? ToastAndroid.SHORT
: ToastAndroid.LONG;
ToastAndroid.showWithGravity(
nativeMessage,
duration,
ToastAndroid.TOP
);
return;
}
idRef.current += 1;
setToast({
id: idRef.current,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
},
[]
);
const api = useMemo<ToastApi>(
() => ({
show,
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
variant: "success",
icon: <CheckCircle2 size={18} />,
}),
error: (message: string) =>
show(message, { variant: "error", durationMs: 3200 }),
}),
[show]
);
const dismiss = useCallback(() => {
setToast(null);
}, []);
return { api, toast, dismiss };
}
export function ToastViewport({
toast,
onDismiss,
placement = "app-shell",
}: {
toast: ToastState | null;
onDismiss: () => void;
placement?: ToastViewportPlacement;
}) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const animateOut = useCallback(() => {
clearTimer();
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -8,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start(({ finished }) => {
if (finished) {
onDismiss();
}
});
}, [clearTimer, onDismiss, opacity, translateY]);
useEffect(() => {
if (!toast) {
clearTimer();
opacity.setValue(0);
translateY.setValue(-8);
return;
}
clearTimer();
opacity.setValue(0);
translateY.setValue(-8);
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
timeoutRef.current = setTimeout(() => {
animateOut();
}, toast.durationMs);
return () => {
clearTimer();
};
}, [animateOut, clearTimer, opacity, toast, translateY]);
if (!toast) {
return null;
}
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
placement === "app-shell"
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
: theme.spacing[3];
const icon =
toast.icon ?? (
toast.variant === "success" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null
);
const content = (
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
toast.variant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
opacity,
transform: [{ translateY }],
},
]}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[
styles.message,
toast.variant === "error" ? styles.messageError : null,
]}
numberOfLines={2}
>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
</View>
);
if (
placement === "app-shell" &&
Platform.OS === "web" &&
typeof document !== "undefined"
) {
return createPortal(content, getOverlayRoot());
}
return content;
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
left: theme.spacing[4],
right: theme.spacing[4],
top: 0,
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "92%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
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,
},
toastSuccess: {
borderColor: theme.colors.border,
},
toastError: {
borderColor: theme.colors.destructive,
},
iconSlot: {
alignItems: "center",
justifyContent: "center",
},
contentSlot: {
flexShrink: 1,
minWidth: 0,
},
message: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
messageError: {
color: theme.colors.foreground,
},
}));

View File

@@ -8,6 +8,7 @@ import {
buildLineDiff,
parseUnifiedDiff,
} from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
@@ -20,6 +21,7 @@ interface ToolCallDetailsContentProps {
errorText?: string;
maxHeight?: number;
fillAvailableHeight?: boolean;
showLoadingSkeleton?: boolean;
}
export function ToolCallDetailsContent({
@@ -27,6 +29,7 @@ export function ToolCallDetailsContent({
errorText,
maxHeight,
fillAvailableHeight = false,
showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) {
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
@@ -240,9 +243,79 @@ export function ToolCallDetailsContent({
);
}
} else if (detail?.type === "search") {
const searchSections: ReactNode[] = [];
if (detail.query) {
searchSections.push(
<View key="search-query" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.query}</Text>
</View>
);
}
if (detail.content) {
searchSections.push(
<View key="search-content" style={styles.section}>
<ScrollView
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
</ScrollView>
</ScrollView>
</View>
);
}
if (detail.filePaths && detail.filePaths.length > 0) {
searchSections.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
</View>
);
}
if (detail.webResults && detail.webResults.length > 0) {
searchSections.push(
<View key="search-web-results" style={styles.section}>
<Text selectable style={styles.scrollText}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>
);
}
if (detail.annotations && detail.annotations.length > 0) {
searchSections.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
</View>
);
}
sections.push(...searchSections);
} else if (detail?.type === "fetch") {
sections.push(
<View key="search" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.query}</Text>
<View
key="fetch"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<ScrollView
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText}>
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
</Text>
</ScrollView>
</ScrollView>
</View>
);
} else if (detail?.type === "plain_text") {
@@ -269,7 +342,11 @@ export function ToolCallDetailsContent({
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined);
].filter((entry) => hasMeaningfulToolCallDetail({
type: "unknown",
input: entry.value ?? null,
output: null,
}));
for (const section of sectionsFromTopLevel) {
let value = "";
@@ -327,6 +404,20 @@ export function ToolCallDetailsContent({
}
if (sections.length === 0) {
if (showLoadingSkeleton) {
return (
<View
style={[
styles.loadingContainer,
fillAvailableHeight && styles.fillHeight,
]}
>
<View style={styles.loadingLineWide} />
<View style={styles.loadingLineMedium} />
<View style={styles.loadingLineShort} />
</View>
);
}
return (
<Text style={styles.emptyStateText}>No additional details available</Text>
);
@@ -471,5 +562,27 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
loadingContainer: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
loadingLineWide: {
height: 12,
width: "100%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineMedium: {
height: 12,
width: "72%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
loadingLineShort: {
height: 12,
width: "48%",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
};
});

View File

@@ -28,6 +28,7 @@ export type ToolCallSheetData = {
summary?: string;
detail?: ToolCallDetail;
errorText?: string;
showLoadingSkeleton?: boolean;
};
interface ToolCallSheetContextValue {
@@ -131,7 +132,7 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { toolName, displayName, detail, errorText } = data;
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
const IconComponent = resolveToolCallIcon(toolName, detail);
@@ -159,6 +160,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
detail={detail}
errorText={errorText}
fillAvailableHeight
showLoadingSkeleton={showLoadingSkeleton}
/>
</BottomSheetScrollView>
</View>

View File

@@ -93,7 +93,7 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
}
interface SearchInputProps {
export interface SearchInputProps {
placeholder: string
value: string
onChangeText: (text: string) => void
@@ -101,7 +101,7 @@ interface SearchInputProps {
autoFocus?: boolean
}
function SearchInput({
export function SearchInput({
placeholder,
value,
onChangeText,
@@ -144,6 +144,7 @@ export interface ComboboxItemProps {
label: string
description?: string
kind?: 'directory' | 'file'
leadingSlot?: ReactNode
selected?: boolean
active?: boolean
onPress: () => void
@@ -154,12 +155,26 @@ export function ComboboxItem({
label,
description,
kind,
leadingSlot,
selected,
active,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles()
const leadingContent = leadingSlot ? (
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
) : kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null
return (
<Pressable
testID={testID}
@@ -171,15 +186,7 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
{leadingContent}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -8,65 +8,62 @@ export const FOOTER_HEIGHT = 75;
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = 48;
export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
// Tauri desktop app constants for macOS traffic light buttons
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Check if running in Tauri desktop app (any OS)
function isTauri(): boolean {
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
return getTauri() !== null;
return isDesktop();
}
// Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean {
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false;
if (getTauri() === null) return false;
// Check for macOS via user agent
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
return isDesktopMac();
}
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
let _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null;
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
export function getIsTauriMac(): boolean {
if (_isTauriMacCached === true) {
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
return true;
}
const result = isTauriMac();
const result = isDesktopEnvironmentMac();
if (result) {
_isTauriMacCached = true;
_isDesktopMacCached = true;
}
return result;
}
export function getIsTauri(): boolean {
if (_isTauriCached === true) {
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
return true;
}
const result = isTauri();
const result = isDesktopEnvironment();
if (result) {
_isTauriCached = true;
_isDesktopCached = true;
}
return result;
}
// Get traffic light padding values (only non-zero on Tauri macOS)
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsTauriMac()) {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: TAURI_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useWindowDimensions } from "react-native";
import {
useSharedValue,
@@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
}
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => {
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
@@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
};
}, [translateX, backdropOpacity]);
const animateToClose = () => {
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
@@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
};
}, [translateX, backdropOpacity, windowWidth]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef]
);
return (
<SidebarAnimationContext.Provider
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}}
>
<SidebarAnimationContext.Provider value={value}>
{children}
</SidebarAnimationContext.Provider>
);

View File

@@ -1,62 +1,9 @@
import { createContext, useContext, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
Animated,
Easing,
Platform,
Text,
ToastAndroid,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
type ToastVariant = "default" | "success" | "error";
export type ToastShowOptions = {
icon?: ReactNode;
variant?: ToastVariant;
durationMs?: number;
/**
* Set to true to use OS toast on Android.
*/
nativeAndroid?: boolean;
testID?: string;
};
type ToastState = {
id: number;
content: ReactNode;
nativeMessage: string | null;
icon?: ReactNode;
variant: ToastVariant;
durationMs: number;
testID?: string;
};
export type ToastApi = {
show: (content: ReactNode, options?: ToastShowOptions) => void;
copied: (label?: string) => void;
error: (message: string) => void;
};
const DEFAULT_DURATION_MS = 2200;
ToastViewport,
useToastHost,
type ToastApi,
} from "@/components/toast-host";
const ToastContext = createContext<ToastApi | null>(null);
@@ -69,259 +16,12 @@ export function useToast(): ToastApi {
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback(
(content: ReactNode, options?: ToastShowOptions) => {
const nativeMessage =
typeof content === "string"
? content.trim()
: null;
if (!content || nativeMessage === "") return;
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs <= 2500
? ToastAndroid.SHORT
: ToastAndroid.LONG;
ToastAndroid.showWithGravity(
nativeMessage,
duration,
ToastAndroid.TOP
);
return;
}
idRef.current += 1;
setToast({
id: idRef.current,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
},
[]
);
const api = useMemo<ToastApi>(
() => ({
show,
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
variant: "success",
icon: <CheckCircle2 size={18} />,
}),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show]
);
const { api, toast, dismiss } = useToastHost();
return (
<ToastContext.Provider value={api}>
{children}
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
<ToastViewport toast={toast} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
function ToastViewport({
toast,
onDismiss,
}: {
toast: ToastState | null;
onDismiss: () => void;
}) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const animateOut = useCallback(() => {
clearTimer();
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -8,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start(({ finished }) => {
if (finished) {
onDismiss();
}
});
}, [clearTimer, onDismiss, opacity, translateY]);
useEffect(() => {
if (!toast) {
clearTimer();
opacity.setValue(0);
translateY.setValue(-8);
return;
}
clearTimer();
opacity.setValue(0);
translateY.setValue(-8);
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 140,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
timeoutRef.current = setTimeout(() => {
animateOut();
}, toast.durationMs);
return () => {
clearTimer();
};
}, [animateOut, clearTimer, opacity, toast, translateY]);
if (!toast) {
return null;
}
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const icon =
toast.icon ?? (
toast.variant === "success" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null
);
const content = (
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
toast.variant === "error" ? styles.toastError : null,
{
marginTop:
insets.top + headerTopPadding + headerHeight + theme.spacing[2],
opacity,
transform: [{ translateY }],
},
]}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[
styles.message,
toast.variant === "error" ? styles.messageError : null,
]}
numberOfLines={2}
>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
</View>
);
// On web, portal to overlay root to control stacking order
if (Platform.OS === "web" && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
return content;
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
left: theme.spacing[4],
right: theme.spacing[4],
top: 0,
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "92%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
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,
},
toastSuccess: {
borderColor: theme.colors.border,
},
toastError: {
borderColor: theme.colors.destructive,
},
iconSlot: {
alignItems: "center",
justifyContent: "center",
},
contentSlot: {
flexShrink: 1,
minWidth: 0,
},
message: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
messageError: {
color: theme.colors.foreground,
},
}));

View File

@@ -1,4 +1,4 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
interface AttachmentFileResult {
path: string;

View File

@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
}));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));

View File

@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);

View File

@@ -10,6 +10,10 @@ export interface DesktopPermissionRowProps {
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
extraActionLabel?: string;
isExtraActionBusy?: boolean;
isExtraActionDisabled?: boolean;
onExtraAction?: () => void;
}
export function DesktopPermissionRow({
@@ -18,6 +22,10 @@ export function DesktopPermissionRow({
isRequesting,
showBorder,
onRequest,
extraActionLabel,
isExtraActionBusy = false,
isExtraActionDisabled = false,
onExtraAction,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
@@ -36,9 +44,21 @@ export function DesktopPermissionRow({
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
<View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
variant="outline"
size="sm"
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
</Button>
) : null}
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
@@ -75,6 +95,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionGrantedActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",

View File

@@ -13,8 +13,10 @@ export function DesktopPermissionsSection() {
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
refreshPermissions,
requestPermission,
sendTestNotification,
} = useDesktopPermissions();
if (!isDesktop) {
@@ -22,6 +24,7 @@ export function DesktopPermissionsSection() {
}
const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
return (
<View style={settingsStyles.section}>
@@ -48,6 +51,12 @@ export function DesktopPermissionsSection() {
onRequest={() => {
void requestPermission("notifications");
}}
extraActionLabel="Test"
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={() => {
void sendTestNotification();
}}
/>
<DesktopPermissionRow
title="Microphone"

View File

@@ -20,55 +20,61 @@ import { Button } from '@/components/ui/button'
import { useAppSettings } from '@/hooks/use-settings'
import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url'
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import {
getCliSymlinkInstructions,
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from '@/desktop/managed-runtime/managed-runtime'
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from '@/desktop/daemon/desktop-daemon'
export interface LocalDaemonSectionProps {
appVersion: string | null
showLifecycleControls: boolean
}
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
export function LocalDaemonSection({
appVersion,
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon()
const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => {
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve()
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status)
setManagedLogs(logs)
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
setDaemonStatus(status)
setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null)
})
.catch((error) => {
@@ -82,35 +88,31 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
if (!showSection) {
return undefined
}
void loadManagedStatus()
void loadDaemonData()
return undefined
}, [loadManagedStatus, showSection])
}, [loadDaemonData, showSection])
)
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonStatusStateText =
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage =
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return
}
if (isRestartingDaemon) {
if (!showSection || isRestartingDaemon) {
return
}
void confirmDialog({
title: daemonActionLabel,
message:
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel,
@@ -124,19 +126,18 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
setIsRestartingDaemon(true)
setStatusMessage(null)
const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
void action()
.then((status) => {
setManagedStatus(status)
setDaemonStatus(status)
setStatusMessage(
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadManagedStatus()
return loadDaemonData()
})
.catch((error) => {
console.error('[Settings] Failed to change managed daemon state', error)
console.error('[Settings] Failed to change desktop daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
})
@@ -145,10 +146,10 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
})
})
.catch((error) => {
console.error('[Settings] Failed to open managed daemon action confirmation', error)
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -189,13 +190,13 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
setStatusMessage(null)
const stopPromise =
managedStatus?.status === 'running'
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
daemonStatus?.status === 'running'
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null)
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => loadDaemonData())
.then(() => {
setStatusMessage('Built-in daemon paused and stopped.')
})
@@ -212,9 +213,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
])
@@ -254,7 +255,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
}, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath
const logPath = daemonLogs?.logPath
if (!logPath) {
return
}
@@ -267,14 +268,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [managedLogs?.logPath])
}, [daemonLogs?.logPath])
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
if (!daemonLogs) {
return
}
setIsLogsModalOpen(true)
}, [managedLogs])
}, [daemonLogs])
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
@@ -285,7 +286,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
setIsLoadingPairing(true)
setPairingStatusMessage(null)
void getManagedDaemonPairing()
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) {
@@ -340,64 +341,68 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
{isDaemonManagementPaused
? 'Paused. The built-in daemon stays stopped until you start it again.'
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? 'Resuming...'
: 'Pausing...'
: isDaemonManagementPaused
? 'Resume'
: 'Pause'}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? managedStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
</Button>
</View>
{showLifecycleControls ? (
<>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
{isDaemonManagementPaused
? 'Paused. The built-in daemon stays stopped until you start it again.'
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? 'Resuming...'
: 'Pausing...'
: isDaemonManagementPaused
? 'Resume'
: 'Pause'}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
@@ -417,10 +422,10 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
</View>
<View style={styles.actionGroup}>
{managedLogs?.logPath ? (
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
@@ -435,7 +440,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!managedLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
@@ -515,9 +520,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
snapPoints={['70%', '92%']}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
</Text>
</View>
</AdaptiveModalSheet>
@@ -529,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: {
isLoading: boolean
pairingOffer: ManagedPairingOffer | null
pairingOffer: DesktopPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
}) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
vi.mock("./desktop-daemon", () => ({
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
describe("desktop-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
desktopDaemonMock.openLocalTransportSession.mockReset();
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
desktopDaemonMock.sendLocalTransportMessage.mockClear();
desktopDaemonMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
desktopDaemonMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,7 +5,7 @@ import {
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
} from "./desktop-daemon";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -49,7 +49,7 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;

View File

@@ -1,29 +1,24 @@
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
import { getDesktopHost, isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export type ManagedRuntimeStatus = {
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
export type ManagedDaemonStatus = {
runtimeId: string
runtimeVersion: string
export type DesktopDaemonStatus = {
serverId: string
status: string
status: DesktopDaemonState
listen: string
hostname: string | null
pid: number | null
home: string
error: string | null
}
export type ManagedDaemonLogs = {
export type DesktopDaemonLogs = {
logPath: string
contents: string
}
export type ManagedPairingOffer = {
export type DesktopPairingOffer = {
relayEnabled: boolean
url: string | null
qr: string | null
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
commands: string
}
export type ManagedTcpSettings = {
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = {
transportType: 'socket' | 'pipe'
transportPath: string
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed runtime status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
const normalized = toStringOrNull(value)?.toLowerCase()
switch (normalized) {
case 'starting':
return 'starting'
case 'running':
return 'running'
case 'errored':
case 'error':
return 'errored'
case 'stopped':
case 'stopping':
case 'unknown':
default:
return 'stopped'
}
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon status response.')
throw new Error('Unexpected desktop daemon status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown',
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
error: toStringOrNull(raw.error),
}
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon logs response.')
throw new Error('Unexpected desktop daemon logs response.')
}
return {
logPath: toStringOrNull(raw.logPath) ?? '',
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.')
throw new Error('Unexpected desktop daemon pairing response.')
}
return {
relayEnabled: raw.relayEnabled === true,
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null
export function shouldUseDesktopDaemon(): boolean {
return isDesktop()
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
)
}
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen
const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') {
throw new Error('Tauri event API is unavailable.')
throw new Error('Desktop events API is unavailable.')
}
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) {
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
if (!isRecord(payload)) {
return
}
handler({

View File

@@ -0,0 +1,27 @@
import { getDesktopHost } from "@/desktop/host";
export type DesktopEventUnlisten = () => void;
type EventEnvelope = {
payload?: unknown;
};
export async function listenToDesktopEvent<TPayload>(
event: string,
handler: (payload: TPayload) => void
): Promise<DesktopEventUnlisten> {
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
throw new Error("Desktop event API is unavailable.");
}
const unlisten = await listen(event, (rawEvent: unknown) => {
const payload =
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
? (rawEvent as EventEnvelope).payload
: rawEvent;
handler(payload as TPayload);
});
return typeof unlisten === "function" ? unlisten : () => {};
}

View File

@@ -0,0 +1,12 @@
import type { DesktopHostBridge } from "@/desktop/host";
export function getElectronHost(): DesktopHostBridge | null {
if (typeof window === "undefined") {
return null;
}
const host = window.paseoDesktop;
if (!host || typeof host !== "object") {
return null;
}
return host;
}

View File

@@ -1,13 +1,12 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const invoke = getTauri()?.core?.invoke;
const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri invoke() is unavailable in this environment.");
throw new Error("Desktop invoke() is unavailable in this environment.");
}
return (await invoke(command, args)) as T;
}

View File

@@ -0,0 +1,29 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
return;
}
await win.toggleMaximize();
}
export async function isDesktopFullscreen(): Promise<boolean> {
const win = getDesktopWindow();
if (!win || typeof win.isFullscreen !== "function") {
return false;
}
return await win.isFullscreen();
}

View File

@@ -0,0 +1,113 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
export type DesktopNotificationPermission = "granted" | "denied" | "default";
export interface DesktopDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
open?: (
options?: DesktopDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface DesktopNotificationBridge {
isSupported?: () => Promise<boolean>;
sendNotification?: (
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
) => Promise<boolean>;
}
export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
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>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopWindowModuleBridge {
getCurrentWindow?: () => DesktopWindowBridge;
}
export interface DesktopEventsBridge {
on?: (
event: string,
handler: (payload: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
}
declare global {
interface Window {
paseoDesktop?: DesktopHostBridge;
}
}
export function getDesktopHost(): DesktopHostBridge | null {
if (Platform.OS !== "web") {
return null;
}
return getElectronHost();
}
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {
return false;
}
const hostPlatform = getDesktopHost()?.platform?.toLowerCase();
if (hostPlatform === "darwin" || hostPlatform === "mac" || hostPlatform === "macos") {
return true;
}
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliSymlinkInstructions", () => {
it("parses CLI symlink instructions from the desktop backend", () => {
expect(
parseCliSymlinkInstructions({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
})
).toEqual({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
});
});
it("rejects non-object payloads", () => {
expect(() => parseCliSymlinkInstructions(null)).toThrow(
"Unexpected CLI symlink instructions response."
);
});
});

View File

@@ -1,58 +0,0 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}

View File

@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
}
function setNavigator(value: unknown): void {
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else {
delete (globalThis as { navigator?: unknown }).navigator
}
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
restoreGlobals()
})
it('shows section only in Tauri web runtime', async () => {
it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { isPermissionGranted },
class MockNotification {
static permission = 'default'
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted')
expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number')
})
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
)
})
it('requests notification permission via Tauri', async () => {
const requestPermission = vi.fn(async () => 'granted')
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { requestPermission },
it('requests notification permission via the browser Notification API', async () => {
class MockNotification {
static permission = 'default'
static requestPermission = vi.fn(async () => 'granted')
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted')
expect(requestPermission).toHaveBeenCalledTimes(1)
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
})
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
it('reads browser Notification permission when available', async () => {
class MockNotification {
static permission = 'denied'
}

View File

@@ -1,5 +1,5 @@
import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export type DesktopPermissionKind = 'notifications' | 'microphone'
@@ -45,7 +45,7 @@ type NavigatorLike = {
}
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && getDesktopHost() !== null
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
})
}
function mapTauriNotificationPermissionResult(
permission: TauriNotificationPermission
): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
const desktopHost = getDesktopHost()
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
try {
const granted = await tauriNotification.isPermissionGranted()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
const supported = await desktopHost.notification.isSupported()
return status({
state: 'not-granted',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
})
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
state: supported ? 'granted' : 'unavailable',
detail: supported
? 'Desktop notifications are supported.'
: 'Desktop notifications are not supported on this platform.',
})
} catch {
// Fall through to web API check
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return mapNotificationPermissionString(NotificationConstructor.permission)
}
return mapNotificationPermissionString(NotificationConstructor.permission)
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
try {
const permission = await tauriNotification.requestPermission()
return mapTauriNotificationPermissionResult(permission)
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {

View File

@@ -6,14 +6,17 @@ import {
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
} from "@/desktop/permissions/desktop-permissions";
import { sendOsNotification } from "@/utils/os-notifications";
export interface UseDesktopPermissionsReturn {
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
@@ -34,6 +37,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null
);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
useEffect(() => {
return () => {
@@ -109,6 +113,29 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktop, refreshPermissions]
);
const sendTestNotification = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsSendingTestNotification(true);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");
}
} catch (error) {
console.error("[Settings] Failed to send desktop test notification", error);
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
}
}
}, [isDesktop]);
useEffect(() => {
if (!isDesktop) {
return;
@@ -122,7 +149,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
refreshPermissions,
requestPermission,
sendTestNotification,
};
}

View File

@@ -0,0 +1,23 @@
import { getDesktopHost } from '@/desktop/host'
export async function pickDirectory(): Promise<string | null> {
const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') {
throw new Error('Desktop dialog open() is unavailable in this environment.')
}
const selection = await open({
directory: true,
multiple: false,
})
if (selection === null) {
return null
}
if (typeof selection === 'string') {
return selection
}
throw new Error('Unexpected directory picker response.')
}

View File

@@ -1,6 +1,6 @@
import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && isDesktop()
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {

View File

@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktop) return;
void checkForUpdates({ silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true });
}, CHECK_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isDesktop, checkForUpdates]);
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<Pressable
onPress={() => setDismissed(true)}
hitSlop={8}
style={styles.closeButton}
>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>
{isInstalled ? "Update installed" : "Update available"}
</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
<Pressable
onPress={() => void openExternalUrl(CHANGELOG_URL)}
style={({ pressed }) => [
styles.outlineButton,
pressed && styles.buttonPressed,
]}
>
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
isInstalling && styles.buttonDisabled,
]}
>
<Text style={styles.primaryButtonText}>
{isInstalling ? "Installing..." : "Install & restart"}
</Text>
</Pressable>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
bottom: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
banner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[4],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.xl,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
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,
maxWidth: 480,
},
closeButton: {
position: "absolute",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
alignItems: "center",
},
outlineButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
outlineButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
primaryButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.foreground,
},
primaryButtonText: {
color: theme.colors.surface0,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
buttonPressed: {
opacity: 0.8,
},
buttonDisabled: {
opacity: 0.5,
},
}));

View File

@@ -1,21 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({
const desktopHostState = vi.hoisted(() => ({
api: null as any,
}));
vi.mock("@/utils/tauri", () => ({
getTauri: () => tauriState.api,
vi.mock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
} from "./image-attachment-picker";
describe("image-attachment-picker", () => {
beforeEach(() => {
tauriState.api = null;
desktopHostState.api = null;
});
it("normalizes a picked File into a blob source", async () => {
@@ -69,13 +69,13 @@ describe("image-attachment-picker", () => {
expect(result[0]?.mimeType).toBe("image/png");
});
it("uses the tauri dialog api when available", async () => {
it("uses the desktop dialog api when available", async () => {
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = {
desktopHostState.api = {
dialog: { open },
};
const result = await openImagePathsWithTauriDialog();
const result = await openImagePathsWithDesktopDialog();
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("falls back to core invoke for the tauri dialog plugin", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
tauriState.api = {
core: { invoke },
};
it("throws when desktop dialog API is not available", async () => {
desktopHostState.api = {};
const result = await openImagePathsWithTauriDialog();
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
"Desktop dialog API is not available."
);
});
});

View File

@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
@@ -77,15 +77,15 @@ export async function normalizePickedImageAssets(
);
}
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
function normalizeDesktopDialogSelection(selection: string | string[] | null): string[] {
if (!selection) {
return [];
}
return Array.isArray(selection) ? selection : [selection];
}
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
const tauri = getTauri();
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
const desktop = getDesktopHost();
const options = {
directory: false,
multiple: true,
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
title: "Attach images",
};
const dialogOpen = tauri?.dialog?.open;
if (typeof dialogOpen === "function") {
return normalizeTauriDialogSelection(await dialogOpen(options));
const dialogOpen = desktop?.dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const invoke = tauri?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri dialog API is not available.");
}
const result = await invoke("plugin:dialog|open", { options });
return normalizeTauriDialogSelection(
Array.isArray(result) || typeof result === "string" || result === null ? result : null
);
return normalizeDesktopDialogSelection(await dialogOpen(options));
}

View File

@@ -5,7 +5,7 @@ import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-comm
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
import { useAutocomplete } from './use-autocomplete'
import { useSessionStore } from '@/stores/session-store'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import {
applyFileMentionReplacement,
findActiveFileMention,
@@ -138,7 +138,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return agentCwd.trim()
}, [agentCwd, isDraftContext, queryDraftConfig])
const { client, isConnected } = useHostRuntimeSession(serverId)
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const mode: 'command' | 'file' | null = showFileAutocomplete
? 'file'

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
@@ -48,7 +48,8 @@ export function useAgentCommandsQuery({
enabled = true,
draftConfig,
}: UseAgentCommandsQueryOptions) {
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: commandsQueryKey(serverId, agentId, draftConfig),

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueries } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -10,7 +10,7 @@ import type {
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
// Explicit overrides from URL params or "New Agent" button
@@ -81,10 +81,13 @@ type UseAgentFormStateResult = {
agentDefinition?: AgentProviderDefinition;
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean;
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
};
@@ -364,7 +367,8 @@ export function useAgentFormState(
}, [isVisible]);
// Session state for provider model listing
const { client, isConnected } = useHostRuntimeSession(formState.serverId ?? "");
const client = useHostRuntimeClient(formState.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
@@ -440,6 +444,45 @@ export function useAgentFormState(
const availableModels = providerModelsQuery.data ?? null;
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(def.id as AgentProvider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const allProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < providerDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(providerDefinitions[i]!.id, query.data);
}
}
return map;
}, [allProviderModelQueries, providerDefinitions]);
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
return combineInitialValues(initialValues, initialServerId);
@@ -568,6 +611,25 @@ export function useAgentFormState(
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
setFormState((prev) => ({
...prev,
provider,
model: modelId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
},
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences, updateProviderPreferences]
);
const setModeFromUser = useCallback(
(modeId: string) => {
setFormState((prev) => ({ ...prev, modeId }));
@@ -679,10 +741,13 @@ export function useAgentFormState(
agentDefinition,
modeOptions,
availableModels: availableModels ?? [],
allProviderModels,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
}),
@@ -706,10 +771,13 @@ export function useAgentFormState(
agentDefinition,
modeOptions,
availableModels,
allProviderModels,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
]

View File

@@ -3,8 +3,8 @@ import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
useHostRuntimeConnectionStatus,
useHostRuntimeIsDirectoryLoading,
} from "@/runtime/host-runtime";
import type {
AggregatedAgent,
@@ -88,14 +88,14 @@ export function useAllAgentsList(options?: {
const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents ?? null : null
);
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId || snapshot?.connectionStatus !== "online") {
if (!serverId || connectionStatus !== "online") {
return;
}
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
}, [runtime, serverId, connectionStatus]);
const agents = useMemo(() => {
if (!serverId || !liveAgents) {
@@ -111,7 +111,7 @@ export function useAllAgentsList(options?: {
});
}, [daemons, includeArchived, liveAgents, serverId]);
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;

View File

@@ -194,20 +194,13 @@ export function useArchiveAgent() {
},
});
const archiveMutateAsync = archiveMutation.mutateAsync;
const archiveAgent = useCallback(
async (input: ArchiveAgentInput): Promise<void> => {
if (
isAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
})
) {
return;
}
await archiveMutation.mutateAsync(input);
await archiveMutateAsync(input);
},
[archiveMutation, queryClient]
[archiveMutateAsync]
);
const isArchivingAgent = useCallback(

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}

View File

@@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
@@ -56,7 +56,8 @@ export function useCheckoutDiffQuery({
enabled = true,
}: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
@@ -21,7 +21,8 @@ export function useCheckoutPrStatusQuery({
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions) {
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),

View File

@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import {
checkoutStatusRevalidationKey,
@@ -30,7 +30,8 @@ function fetchCheckoutStatus(
}
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
@@ -87,7 +88,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
* only the visible agents.
*/
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { client } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
return useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd),

View File

@@ -6,20 +6,23 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import {
buildHostOpenProjectRoute,
buildHostWorkspaceAgentRoute,
buildHostSettingsRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
function isMatch(agent: AggregatedAgent, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
@@ -50,7 +53,7 @@ type CommandCenterActionDefinition = {
icon?: "plus" | "settings";
shortcutKeys?: ShortcutKey[];
keywords: string[];
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href;
routeKind: "settings" | "none";
};
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -60,14 +63,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
icon: "plus",
shortcutKeys: ["mod", "shift", "O"],
keywords: ["open", "project", "folder", "workspace", "repo"],
buildRoute: ({ newAgentRoute }) => newAgentRoute,
routeKind: "none",
},
{
id: "settings",
title: "Settings",
icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"],
buildRoute: ({ settingsRoute }) => settingsRoute,
routeKind: "settings",
},
];
@@ -88,7 +91,7 @@ export type CommandCenterActionItem = {
id: string;
title: string;
icon?: "plus" | "settings";
route: Href;
route?: Href;
shortcutKeys?: ShortcutKey[];
};
@@ -118,6 +121,9 @@ export function useCommandCenter() {
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = useMemo(() => {
if (!open) {
return null;
}
const serverIdFromPath = parseServerIdFromPathname(pathname);
if (serverIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
@@ -126,22 +132,20 @@ export function useCommandCenter() {
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, pathname]);
}, [daemons, open, pathname]);
const { agents } = useAllAgentsList({
serverId: activeServerId,
});
const agentResults = useMemo(() => {
if (!open || agents.length === 0) {
return EMPTY_AGENTS;
}
const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents);
return filtered;
}, [agents, query]);
const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
}, [activeServerId]);
}, [agents, open, query]);
const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
@@ -149,6 +153,9 @@ export function useCommandCenter() {
}, [activeServerId]);
const actionItems = useMemo(() => {
if (!open) {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter((action) =>
matchesActionQuery(query, action)
).map<CommandCenterActionItem>((action) => ({
@@ -156,12 +163,15 @@ export function useCommandCenter() {
id: action.id,
title: action.title,
icon: action.icon,
route: action.buildRoute({ newAgentRoute, settingsRoute }),
route: action.routeKind === "settings" ? settingsRoute : undefined,
shortcutKeys: action.shortcutKeys,
}));
}, [newAgentRoute, query, settingsRoute]);
}, [open, query, settingsRoute]);
const items = useMemo(() => {
if (!open) {
return EMPTY_COMMAND_CENTER_ITEMS;
}
const next: CommandCenterItem[] = [];
for (const action of actionItems) {
next.push({
@@ -176,7 +186,7 @@ export function useCommandCenter() {
});
}
return next;
}, [actionItems, agentResults]);
}, [actionItems, agentResults, open]);
const handleClose = useCallback(() => {
setOpen(false);
@@ -185,34 +195,35 @@ export function useCommandCenter() {
const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const route: Href = buildHostWorkspaceAgentRoute(
agent.serverId,
agent.cwd,
agent.id
) as Href;
navigate(route);
const route = prepareWorkspaceTab({
serverId: agent.serverId,
workspaceId: agent.cwd,
target: { kind: "agent", agentId: agent.id },
});
router.navigate(route as any);
},
[pathname, setOpen]
[setOpen]
);
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
setProjectPickerOpen(true);
void openProjectPicker();
return;
}
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
}, [setOpen, setProjectPickerOpen]);
}, [openProjectPicker, setOpen]);
const handleSelectItem = useCallback(
(item: CommandCenterItem) => {

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { ActivityIndicator } from "react-native";
import { useToast } from "@/contexts/toast-context";
import type { ToastShowOptions } from "@/components/toast-host";
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
@@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
interface UseDelayedHistoryRefreshToastParams {
isCatchingUp: boolean;
indicatorColor: string;
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
}
export function useDelayedHistoryRefreshToast({
isCatchingUp,
indicatorColor,
showToast,
}: UseDelayedHistoryRefreshToastParams): void {
const toast = useToast();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasCatchingUpRef = useRef(false);
const isCatchingUpRef = useRef(false);
const toastRef = useRef(toast);
const showToastRef = useRef(showToast);
const indicatorColorRef = useRef(indicatorColor);
useEffect(() => {
toastRef.current = toast;
}, [toast]);
showToastRef.current = showToast;
}, [showToast]);
useEffect(() => {
indicatorColorRef.current = indicatorColor;
@@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({
if (!isCatchingUpRef.current) {
return;
}
toastRef.current.show("Refreshing", {
showToastRef.current("Refreshing", {
icon: (
<ActivityIndicator
size="small"

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
return;
}
try {
await tauriWindow.setBadgeCount(count);
await desktopWindow.setBadgeCount(count);
} catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".tiff": "image/tiff",
};
type TauriDragDropPayload =
type DesktopDragDropPayload =
| {
type: "enter";
paths: string[];
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
type: "leave";
};
type TauriDragDropEvent = {
payload: TauriDragDropPayload;
type DesktopDragDropEvent = {
payload: DesktopDragDropPayload;
};
function isImageFile(file: File): boolean {
@@ -121,26 +121,27 @@ export function useFileDropZone({
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
}
}
async function setupTauriDragDrop(): Promise<boolean> {
if (getTauri() === null) {
async function setupDesktopDragDrop(): Promise<boolean> {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
return false;
}
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
return false;
}
try {
const unlisten = await tauriWindow.onDragDropEvent(
(event: TauriDragDropEvent) => {
const unlisten = await desktopWindow.onDragDropEvent(
(event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
@@ -185,7 +186,7 @@ export function useFileDropZone({
cleanup = unlisten;
return true;
} catch (error) {
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
return false;
}
}
@@ -269,8 +270,8 @@ export function useFileDropZone({
}
void (async () => {
const tauriListenersAttached = await setupTauriDragDrop();
if (disposed || tauriListenersAttached) {
const desktopListenersAttached = await setupDesktopDragDrop();
if (disposed || desktopListenersAttached) {
return;
}
setupDomDragDrop();

View File

@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isTauriEnvironment()) {
const selectedPaths = await openImagePathsWithTauriDialog();
if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;
}

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