Compare commits

...

184 Commits

Author SHA1 Message Date
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
Mohamed Boudra
745f2e2768 chore(release): cut 0.1.28 2026-03-15 13:53:47 +07:00
Mohamed Boudra
ef55fecfe2 docs(changelog): prepare 0.1.28 release notes 2026-03-15 13:53:03 +07:00
Mohamed Boudra
cc675da49c Stabilize git action menu 2026-03-15 13:51:04 +07:00
Mohamed Boudra
a9d5502fad fix(app): route assistant file links to workspace files 2026-03-15 13:50:52 +07:00
Mohamed Boudra
849f49b622 fix: settings screen design tweaks for mobile
- Center settings cog icon vertically in host card
- Normalize icon sizes (relay/host/settings all use iconSize.sm)
- Hide theme picker labels on mobile (icon-only)
- Simplify audio test copy, remove verbose description
2026-03-15 13:50:50 +07:00
Mohamed Boudra
d89f1ea663 fix: align mobile workspace header icons with desktop
Use same explorer toggle icons (SourceControlPanelIcon/PanelRight) on
mobile as desktop, use vertical kebab icon for mobile header menu.
2026-03-15 13:50:42 +07:00
Mohamed Boudra
5701543746 refactor: move startup logic to app-support, improve welcome screen UI 2026-03-15 12:52:29 +07:00
Mohamed Boudra
57db31c662 feat(website): update hero copy, add sponsor CTA section
Update hero title to "All your coding agents, from anywhere" with
responsive line break. Add sponsor CTA section with personal messaging.
Update meta title for SEO. Fix GitHub sponsors link.
2026-03-15 12:07:14 +07:00
Mohamed Boudra
eadca4a96b Remove fix-tests script 2026-03-15 11:27:23 +07:00
Mohamed Boudra
4a111099c4 docs: fix license to AGPL-3.0 in README 2026-03-15 11:25:35 +07:00
Mohamed Boudra
6f34699e4b Add OpenCode build and plan modes 2026-03-15 11:17:23 +07:00
Mohamed Boudra
aa6c521394 feat(website): add SEO landing pages for Claude Code, Codex, and OpenCode
Extract homepage into reusable LandingPage component with configurable
hero. Add /claude-code, /codex, and /opencode routes targeting
agent-specific mobile app search terms. Replace single-link footer
with four-column layout (Product, Agents, Community, Download).
2026-03-15 11:16:28 +07:00
Mohamed Boudra
e80db346da fix(deploy): force production deployment on Cloudflare Pages
Add --branch main to wrangler pages deploy so tag-triggered deploys
go to production instead of preview. Add app-v* tag for retriggering.
2026-03-15 11:08:53 +07:00
Mohamed Boudra
26c7f3f5f3 docs: add GitHub releases link to getting started 2026-03-15 10:57:56 +07:00
Mohamed Boudra
0057ee7b72 docs: update README to match current homepage messaging
- Lead with desktop app download instead of CLI install
- Move CLI install under headless/server mode section
- Remove early development warning
- Add feature highlights from homepage
2026-03-15 10:56:12 +07:00
Mohamed Boudra
0a3e55734b docs(skill): document --wait-timeout option and clarify wait behavior 2026-03-15 10:47:07 +07:00
Mohamed Boudra
0b88383b01 chore: remove stale e2e specs and fix-tests scripts
Delete all 34 Playwright e2e specs — they test against an outdated UI
and will be rewritten from scratch with a clean DSL-based approach.
Remove scripts/fix-tests/ overnight loop infrastructure.
2026-03-15 10:45:36 +07:00
Mohamed Boudra
a364edde17 chore: update fix-tests prompts with known issues and pre-existing failures 2026-03-15 10:41:09 +07:00
Mohamed Boudra
22c6df3f85 feat(cli): add --wait-timeout option to run command
Replace hardcoded 10-minute timeout with configurable --wait-timeout.
Default is no limit, matching the wait command behavior. Extracts
parseDuration to shared utils so both run and wait can use it.

Fixes OUTPUT_SCHEMA_FAILED errors when agents take >10 minutes.
2026-03-15 10:41:06 +07:00
Mohamed Boudra
93a3a742ac Merge branch 'quality-gate-verify-tests-standards' 2026-03-15 10:40:20 +07:00
Mohamed Boudra
0abca5e0c7 fix(server): clean up test suite — remove mocks, delete redundant tests, fix races
- Remove vi.mock() from provider-launch-config.test.ts, use dependency injection instead
- Delete codex-app-server-agent.test.ts integration infrastructure (1900 lines) —
  real codex coverage lives in daemon e2e tests (agent-basics, permissions-codex, etc.)
- Delete claude-agent-commands.test.ts — redundant with daemon e2e suite
- Delete 16-agent-update.test.ts — tested removed functionality
- Fix getAvailablePort race condition with port 0 in bootstrap.ts
- Extract large integration test blocks from unit test files into daemon e2e
- Clean up speech-runtime TTS manager test to use real dependencies
- Net -2258 lines across 17 files, all tests passing
2026-03-15 10:30:44 +07:00
Mohamed Boudra
f275474fec Serialize workspace registry persists 2026-03-14 22:48:46 +07:00
Mohamed Boudra
f12dc6127b feat: add --prompt and --prompt-file options to agent send command 2026-03-14 22:38:13 +07:00
Mohamed Boudra
e526f375c8 Merge branch 'fix-tests-suite-loop'
# Conflicts:
#	package-lock.json
#	packages/server/src/server/speech/speech-config-resolver.ts
#	packages/server/src/shared/tool-call-display.ts
2026-03-14 22:06:42 +07:00
Mohamed Boudra
3ae1102d13 feat: add agent ID/name resolution and provider/model syntax support 2026-03-14 22:04:09 +07:00
Mohamed Boudra
d1632d80e3 refactor(voice): defer speech confirmation and add diagnostics 2026-03-14 20:05:48 +07:00
Mohamed Boudra
cad4110e24 feat: add expo-two-way-audio module to workspace 2026-03-14 16:46:35 +07:00
Mohamed Boudra
fc7369384b feat(voice): implement turn-detection and real-time audio streaming 2026-03-14 13:46:10 +07:00
Mohamed Boudra
c45b414476 chore(release): cut 0.1.27 2026-03-13 21:09:06 +07:00
Mohamed Boudra
73bd0c1dba docs: add 0.1.27 changelog 2026-03-13 21:08:53 +07:00
Mohamed Boudra
502dd2d2e2 refactor(app): remove debug logging from voice and UI components 2026-03-13 21:07:18 +07:00
Mohamed Boudra
80a2f8052c refactor(app): replace voice hooks with voice runtime and audio engine architecture 2026-03-13 20:53:55 +07:00
Mohamed Boudra
a495138374 refactor: simplify connection selection with linear scan 2026-03-13 13:44:25 +07:00
Mohamed Boudra
7f69166e97 refactor: extract detail display logic into helper functions 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2268790656 refactor: consolidate speech provider config resolution logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
3a02d1cdd6 refactor: extract createShortcutTarget and simplify shortcut logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2e560677e7 fix(tests): iteration 9 2026-03-13 13:44:25 +07:00
Mohamed Boudra
b4a6b098a4 fix(tests): iteration 8 2026-03-13 13:44:25 +07:00
Mohamed Boudra
12f8c0db96 fix(tests): iteration 7 2026-03-13 13:44:25 +07:00
Mohamed Boudra
d39c95e0e7 fix(tests): iteration 6 2026-03-13 13:44:25 +07:00
Mohamed Boudra
f74031b50d fix(tests): iteration 4 2026-03-13 13:44:25 +07:00
Mohamed Boudra
8e67f5a707 fix(tests): iteration 1 2026-03-13 13:44:03 +07:00
Mohamed Boudra
a194a901b4 test: remove unimplemented worktree archival tests and unused code 2026-03-13 13:44:03 +07:00
Mohamed Boudra
049edbfcaf fix(e2e): parametrize reply line count and improve scroll behavior 2026-03-13 13:43:34 +07:00
Mohamed Boudra
7c18616160 fix(tests): iteration 20 2026-03-13 13:43:34 +07:00
Mohamed Boudra
70191ceaa8 fix(tests): iteration 19 2026-03-13 13:43:34 +07:00
Mohamed Boudra
99bac0fd27 fix(tests): iteration 18 2026-03-13 13:43:34 +07:00
Mohamed Boudra
e8b55eccab fix(tests): iteration 16 2026-03-13 13:43:34 +07:00
Mohamed Boudra
40ebd1b730 fix(tests): iteration 15 2026-03-13 13:43:34 +07:00
Mohamed Boudra
b5102e7d7e fix(tests): iteration 14 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f3036ad2c9 fix(tests): iteration 13 2026-03-13 13:42:51 +07:00
Mohamed Boudra
4a01b9ec90 fix(tests): iteration 12 2026-03-13 13:42:51 +07:00
Mohamed Boudra
2f13806a2c fix(tests): iteration 11 2026-03-13 13:42:51 +07:00
Mohamed Boudra
5f5599a1a8 fix(tests): iteration 10 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f2680563bd fix(tests): iteration 9 2026-03-13 13:42:51 +07:00
Mohamed Boudra
79d658243d fix(tests): iteration 8 2026-03-13 13:42:51 +07:00
Mohamed Boudra
3994fa65c0 fix(tests): iteration 7 2026-03-13 13:42:51 +07:00
Mohamed Boudra
10671cf999 fix(tests): iteration 6 2026-03-13 13:42:51 +07:00
Mohamed Boudra
40b9b4672e fix(tests): iteration 5 2026-03-13 13:42:51 +07:00
Mohamed Boudra
44fc4bfeff fix(tests): iteration 3 2026-03-13 13:42:51 +07:00
Mohamed Boudra
402f3e32ae fix(tests): iteration 2 2026-03-13 13:42:51 +07:00
Mohamed Boudra
436a595839 fix(tests): iteration 1 2026-03-13 13:42:40 +07:00
Mohamed Boudra
8a360281c6 Add test automation loop and CI secrets for test fixes 2026-03-13 13:42:08 +07:00
Mohamed Boudra
730f34753a fix(app): use platform-specific components for markdown links 2026-03-13 13:27:24 +07:00
Mohamed Boudra
013f573cff refactor: migrate to shell-env and custom desktop notifications bridge 2026-03-13 12:26:14 +07:00
Mohamed Boudra
ee8a81d2e1 feat(server): support Grep tool in Claude tool-call mapping 2026-03-13 10:32:33 +07:00
Mohamed Boudra
bed03be332 feat(app): support file:// protocol URLs for opening workspace files 2026-03-13 10:24:01 +07:00
Mohamed Boudra
debe4b543b fix(desktop): resolve linux appimage cli resources 2026-03-13 09:58:01 +07:00
Mohamed Boudra
1c601e09b6 fix(release): repair linux appimage symlinks 2026-03-13 09:38:59 +07:00
Mohamed Boudra
ba54aae206 fix(server): pin sherpa runtime deps 2026-03-13 08:48:28 +07:00
Mohamed Boudra
36961f83bf fix(release): recover desktop r8 failures 2026-03-12 23:49:07 +07:00
Mohamed Boudra
6db99c03cb feat(app): allow agents to open workspace files from chat 2026-03-12 23:43:59 +07:00
Mohamed Boudra
999d100464 fix(release): recover desktop runtime packaging 2026-03-12 23:33:50 +07:00
Mohamed Boudra
9190d86148 feat(app): improve image picker, markdown rendering, and UI interactions 2026-03-12 23:29:26 +07:00
Mohamed Boudra
4b5cb5a3e2 fix(release): recover desktop packaging artifacts 2026-03-12 23:20:26 +07:00
Mohamed Boudra
847e1d2c60 fix(app): avoid metro exclusionList breakage on windows 2026-03-12 23:06:36 +07:00
Mohamed Boudra
970df6478b fix(app): make metro blocklist regex windows-safe 2026-03-12 22:56:46 +07:00
Mohamed Boudra
3371f17606 fix(release): add manual linux appimage fallback 2026-03-12 22:55:18 +07:00
Mohamed Boudra
0f65fe894c fix(release): preload metro patch inline on windows 2026-03-12 22:52:15 +07:00
Mohamed Boudra
2ac27a3e3e fix(release): support iterative desktop recovery tags 2026-03-12 22:48:26 +07:00
Mohamed Boudra
defcc54af2 fix(release): harden desktop recovery workflows 2026-03-12 22:41:34 +07:00
Mohamed Boudra
fe84c5c9a3 Match explorer files pane sidebar background 2026-03-12 22:34:42 +07:00
Mohamed Boudra
2ff8041e15 Fix hidden sidebar workspace shortcuts 2026-03-12 22:33:27 +07:00
Mohamed Boudra
1d3e551e7f fix(release): recover desktop release builds 2026-03-12 22:26:44 +07:00
Mohamed Boudra
79795b6f49 Update outdated Paseo skill CLI options 2026-03-12 22:26:10 +07:00
Mohamed Boudra
16da3300e3 fix: prevent Codex replacement stream from being killed by stale turn_completed notification
The turn_completed notification handler called eventQueue.end(), which
could terminate a replacement stream's queue if the old interrupted
turn's completion arrived after the new stream had set up its queue.
The streamInternal loop already breaks on terminal events, making the
end() call redundant and unsafe during replaceAgentRun.
2026-03-12 22:24:41 +07:00
Mohamed Boudra
69d8427bd9 chore(release): cut 0.1.26 2026-03-12 21:34:33 +07:00
Mohamed Boudra
d8ccbd5c32 docs: update CHANGELOG for 0.1.26 2026-03-12 21:34:22 +07:00
Mohamed Boudra
be93f8b240 refactor: remove performance monitoring and diagnostics infrastructure 2026-03-12 21:15:01 +07:00
Mohamed Boudra
b09a731d85 refactor: replace welcome message with server_info status payload
Unify the initial connection handshake and capability update paths.
The server now sends a server_info status message on connect and
whenever capabilities change, enabling the onboarding flow where
voice becomes available after models are configured.
2026-03-12 20:38:03 +07:00
Mohamed Boudra
554017b0b8 refactor: move daemon registry into host-runtime store 2026-03-12 19:02:06 +07:00
Mohamed Boudra
9f089be946 refactor: rename sockPath to listen in pid lock, add startup timing instrumentation 2026-03-12 14:19:21 +07:00
Mohamed Boudra
213f155c9c fix(desktop): fix Claude agent spawn from managed runtime and rotate logs on restart
- Always override spawnClaudeCodeProcess to use process.execPath instead of
  SDK's PATH-based "node" lookup, which fails in the managed runtime bundle
- Fix append-mode arg ordering in resolveClaudeSpawnCommand so extra CLI args
  (e.g. --chrome) go after cli.js, not before it (Node exit code 9)
- Rotate daemon.log on every daemon restart for clean startup logs
- Remove dead dev_resource_root fallback from runtime_manager.rs
- Canonicalize current_exe path in CLI runner
2026-03-12 13:16:08 +07:00
Mohamed Boudra
9e6b45e2f0 refactor(cli): remove daemon update command and simplify status runtime info 2026-03-12 11:19:33 +07:00
Mohamed Boudra
355e56db53 fix(server): add trace logging for Codex app server spawn 2026-03-12 10:51:31 +07:00
Mohamed Boudra
a79134ec9e feat: add single-instance support, Android APK download, and splash screen styling 2026-03-12 10:42:40 +07:00
Mohamed Boudra
91dde29146 feat(server): bundle Codex and OpenCode binaries instead of requiring global installs 2026-03-12 10:25:53 +07:00
Mohamed Boudra
586b48e150 Update files 2026-03-11 23:13:36 +07:00
Mohamed Boudra
aeefa22ddf fix: hide chrome on home route 2026-03-11 21:34:16 +07:00
Mohamed Boudra
9f3ef07322 docs: extract guidance into dedicated docs, streamline CLAUDE.md 2026-03-11 21:23:02 +07:00
Mohamed Boudra
e2cb67462d refactor(cli): extract common command options into reusable helpers 2026-03-11 21:08:08 +07:00
Mohamed Boudra
b60d253926 fix: update metro exclusionList import for Expo compatibility 2026-03-11 21:01:31 +07:00
Mohamed Boudra
ee156adffb Merge remote-tracking branch 'origin/improve-startup' 2026-03-11 20:10:11 +07:00
Mohamed Boudra
c99b78f5b0 Handle noisy shell output in executable lookup 2026-03-11 19:05:44 +07:00
Mohamed Boudra
4c6d21af4a refactor(desktop): simplify managed runtime by removing state file and delegating to CLI daemon status 2026-03-11 19:01:49 +07:00
Mohamed Boudra
327b315610 Suppress console windows on Windows and use ~/.paseo as default home
Add CREATE_NO_WINDOW flag to all Windows process spawns to prevent
visible console windows. Change Windows default managed home from
AppData\Roaming to ~/.paseo for consistency with macOS and server.
2026-03-11 15:52:07 +07:00
Mohamed Boudra
78849fa0bf Strip \\?\ extended-length prefix from Windows resource paths
Node.js module resolver can't handle the \\?\ prefix that Tauri's
resource_dir() returns on Windows, causing EISDIR errors. Use dunce
to simplify paths before passing them to Node.
2026-03-11 14:51:47 +07:00
Mohamed Boudra
e5014a5f57 Add Discord link to website navigation and changelog 2026-03-11 14:21:23 +07:00
Mohamed Boudra
5bf698ff84 Add Windows support and improve cross-platform shell execution 2026-03-11 14:14:06 +07:00
Mohamed Boudra
eb5f011161 Add screen orientation polyfill, refactor provider launch config, and update desktop runtime manager
- Add .claude schedule tasks to .gitignore
- Add screen-orientation polyfill for Expo web
- Refactor agent provider launch config into shared utility
- Update desktop runtime manager with improved binary management
- Update desktop release workflow
2026-03-11 14:01:07 +07:00
Mohamed Boudra
acfb933ee8 Update CHANGELOG for 0.1.25 2026-03-11 12:29:39 +07:00
626 changed files with 86036 additions and 37203 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*'
- 'app-v*'
workflow_dispatch:
jobs:

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,6 +31,8 @@ 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:
publish-macos:
@@ -40,9 +42,9 @@ jobs:
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
@@ -59,13 +61,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -77,85 +77,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:
@@ -164,76 +118,36 @@ 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 }}
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
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:
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'))) }}
@@ -253,13 +167,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -271,89 +183,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'
@@ -363,33 +224,30 @@ 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 Linux 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 }}
NO_STRIP: "true"
APPIMAGE_EXTRACT_AND_RUN: "1"
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 appimage
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 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:
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'))) }}
@@ -409,13 +267,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -427,71 +283,42 @@ 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
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
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: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
@@ -501,31 +328,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,msi
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[@]}"

View File

@@ -44,3 +44,6 @@ jobs:
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

5
.gitignore vendored
View File

@@ -46,6 +46,9 @@ test-results/
# Vercel
.vercel/
# Expo
.expo/
# Misc
*.pem
.vercel
@@ -71,6 +74,8 @@ valknut-report.json/
**/.paseo-provider-history/
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt

View File

@@ -1,5 +1,77 @@
# 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
- Added OpenCode build and plan modes.
- Added website landing pages for Claude Code, Codex, and OpenCode.
### Improved
- Improved the git action menu for more reliable repository actions.
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
- Updated the website hero copy and added a sponsor callout section.
### Fixed
- Fixed assistant file links so they open the correct workspace files from chat.
## 0.1.27 - 2026-03-13
### Added
- Added voice runtime with new audio engine architecture for voice interactions.
- Added Grep tool support in Claude tool-call mapping.
- Added ability to open workspace files directly from agent chat messages.
- Added desktop notifications via a custom native bridge.
### Improved
- Improved image picker, markdown rendering, and UI interactions.
- Improved shell environment detection using shell-env.
### Fixed
- Fixed platform-specific markdown link rendering.
- Fixed Linux AppImage CLI resource paths.
- Fixed Codex replacement stream being killed by stale turn notifications.
## 0.1.26 - 2026-03-12
### Added
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
- Added Windows support with improved cross-platform shell execution.
### Improved
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
- Added a Discord link to the website navigation.
### Fixed
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
- Fixed the home route to hide browser chrome when appropriate.
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
- Fixed noisy shell output interfering with executable lookup.
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
## 0.1.25 - 2026-03-11
### Fixed
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
## 0.1.24 - 2026-03-10
### Improved
@@ -94,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
@@ -116,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
@@ -155,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

281
CLAUDE.md
View File

@@ -1,267 +1,54 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
**Key features:**
- Real-time streaming of agent output
- Voice commands for hands-free interaction
- Push notifications when tasks complete
- Multi-agent orchestration across projects
**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
## Monorepo Structure
## Repository map
This is an npm workspace monorepo:
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
- `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` — Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Development Server
## Documentation
The `npm run dev` script automatically picks an available port for the development server.
| Doc | What's in it |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
## Quick start
```bash
PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
For trace+ logs, check $PASEO_HOME/daemon.log
## Running and checking logs
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
## Debugging
### Daemon and CLI
The Paseo daemon communicates via WebSocket. In the main checkout:
- Daemon runs at `localhost:6767`
- Expo app at `localhost:8081`
- State lives in `$PASEO_HOME`
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
### Relay build sync (important)
When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`.
### Server build sync for CLI (important)
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts.
### Quick reference CLI commands
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
```
### Agent state
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
Agent data is stored at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
## Critical rules
To find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
To find an agent by title or other content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
rg -l "spiteful-toad" $PASEO_HOME/agents/
```
## Orchestrator mode
### Provider session files
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
**Claude sessions:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex sessions:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Android
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
### Android variants (vanilla Expo)
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
- `production` -> app name `Paseo`, package `sh.paseo`
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
`development` uses Android `debug`.
### Local build + install (Android device)
From `packages/app`:
```bash
# development (debug)
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# production (release)
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# clean native project (when needed)
npx expo prebuild --platform android --clean --non-interactive
```
From repo root:
```bash
npm run android:development
npm run android:production
npm run android:clean
```
`npm run android:release` is an alias for `npm run android:production`.
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
That workflow does:
- Build iOS with the `production` profile
- Build Android with the `production` profile
- Submit each build with the `production` submit profile
Useful commands:
```bash
# List recent mobile workflow runs
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect one run (jobs, status, outputs)
cd packages/app && npx eas workflow:view <run-id>
# Stream logs for all steps in one failed job
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
```
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
## Expo troubleshooting
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
## Release playbook
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
```bash
# Recommended: full patch release (bump, check, publish, push branch+tag)
npm run release:patch
# Manual, step-by-step fallback:
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
```
### Draft release flow
```bash
# Stage a draft GitHub release with assets, but do not publish npm yet.
npm run draft-release:patch
# Publish npm and promote the same GitHub draft release to final.
npm run release:finalize
```
Behavior:
- `draft-release:patch` bumps the version, runs release checks, pushes `HEAD` and the new `v*` tag, and creates the matching GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to that same draft release.
- `release:finalize` requires that the current tag already has a GitHub draft release, publishes the npm packages for that exact version, and promotes the same GitHub Release from draft to published.
- Use the same semver tag for both draft and final states; do not cut a second tag just to publish the release.
Notes:
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
Release completion checklist:
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed. The notes are user-facing:
- Ask yourself, what do Paseo users want to know about?
- Include: New features, bug fixes
- Don't include: Refactors or code changes that are not noticeable by users
- `npm run release:patch` completes successfully.
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
- GitHub `Android APK Release` workflow for the same tag is green.
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
## Orchestrator Mode
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
## Agent Authentication
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
## NEVER DO THESE THINGS
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
- Launch agents in the most permissive mode
- Set cwd to the repository root
- When agent control tool calls fail, list agents first — it may be a wait timeout

View File

@@ -4,7 +4,7 @@
<h1 align="center">Paseo</h1>
<p align="center">Manage coding agents from your phone and desktop.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -12,19 +12,27 @@
---
> [!WARNING]
> **Early development** — Features may break or change without notice. Use at your own risk.
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Headless / server mode
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
Then open the app and connect to your daemon.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
@@ -36,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`)
@@ -57,4 +65,4 @@ npm run typecheck
## License
MIT
AGPL-3.0

67
docs/ANDROID.md Normal file
View File

@@ -0,0 +1,67 @@
# Android
## App variants
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
|---|---|---|
| `production` | Paseo | `sh.paseo` |
| `development` | Paseo Debug | `sh.paseo.debug` |
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
`development` uses Android `debug`.
## Local build + install
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
Or from `packages/app`:
```bash
# Debug
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# Release
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
## Screenshots
```bash
adb exec-out screencap -p > screenshot.png
```
## Cloud build + submit (EAS)
Tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
```

184
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,184 @@
# Architecture
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
Your code never leaves your machine. Paseo is local-first.
## System overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Electron) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
│ (direct or │ (direct) │ + WebSocket
│ via relay) │ │
└───────────┬───────┴──────────────────┘
┌──────▼──────┐
│ Daemon │
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode │
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
```
## Packages
### `packages/server` — The daemon
The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Exposes an MCP server for agent-to-agent control
- Optionally connects outbound to a relay for remote access
**Key modules:**
| Module | Responsibility |
|---|---|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
- `DaemonRegistryContext` manages saved daemon connections
- `SessionContext` wraps the daemon client for the active session
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands:
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
- `paseo daemon start/stop/restart/status/pair`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree ls/archive`
Communicates with the daemon via the same WebSocket protocol as the app.
### `packages/relay` — E2E encrypted relay
Enables remote access when the daemon is behind a firewall.
- ECDH key exchange + AES-256-GCM encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
- Same WebSocket client as mobile app
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same binary-multiplexed WebSocket protocol.
**Handshake:**
```
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
```
**Message types:**
- `agent_update` — Agent state changed (status, title, labels)
- `agent_stream` — New timeline event from a running agent
- `workspace_update` — Workspace state changed
- `agent_permission_request` — Agent needs user approval for a tool call
- Command-response pairs for fetch, list, create, etc.
**Binary multiplexing:**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
## Agent lifecycle
```
initializing → idle → running → idle (or error → closed)
↑ │
└────────┘ (agent completes a turn, awaits next prompt)
```
- **AgentManager** tracks up to 200 timeline items per agent
- Timeline is append-only with epochs (each run starts a new epoch)
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
## Agent providers
Each provider implements a common `AgentClient` interface:
| Provider | Wraps | Session format |
|---|---|---|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
2. Session routes to `AgentManager.create()`
3. AgentManager creates a `ManagedAgent`, initializes provider session
4. Provider runs the agent → emits `AgentStreamEvent` items
5. Events append to the agent timeline, broadcast to all subscribed clients
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
7. Permission requests flow: agent → server → client → user decision → server → agent
## Storage
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
└── daemon.log # Daemon trace logs
```
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

174
docs/CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,174 @@
# Coding Standards
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
## Core principles
- **Zero complexity budget** — justify every abstraction with specific benefits
- **Fully typed TypeScript** — no `any`, no untyped boundaries
- **YAGNI** — build features and abstractions only when needed
- **Functional and declarative** over object-oriented
- **`interface`** over `type` when possible
- **`function` declarations** over arrow function assignments
- **Single-purpose functions** — one function, one job
- **Design for edge cases through types** rather than explicit handling
- **Don't catch errors** unless there's a strong reason to
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
- **No "while I'm at it" improvements** — stay focused on the task
## Type hygiene
### Infer from schemas
Never hand-write a TypeScript type that can be inferred from a Zod schema.
```typescript
// Bad: duplicate type that can drift
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
type RPCArgs = { procedure: string; args: Record<string, unknown> };
// Good: infer from schema
type RPCArgs = z.infer<typeof schema>;
```
### Named types over inline
No complex inline types in public function signatures.
```typescript
// Bad
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
// Good
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
function enqueueJob(input: EnqueueJobInput) {}
```
### Object parameters
If a function needs more than one argument, use a single object parameter.
```typescript
// Bad: positional args
function createToolCall(provider: string, toolName: string, payload: unknown) {}
// Good: object param
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
function createToolCall(input: CreateToolCallInput) {}
```
### One canonical type per concept
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
```typescript
// Bad: duplicated fields across layers
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
// Good: canonical type + wrappers
type ToolCall = { toolName: string; args: Record<string, unknown> };
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
```
## Make impossible states impossible
Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
## Optionality is a design decision
Don't mark fields optional to avoid migrations. Decide deliberately:
1. Is optionality actually needed?
2. If there are distinct valid states → discriminated union
3. If value can be intentionally empty → explicit `null`
4. Keep optionality at real boundaries (external input), then resolve it
## Validate at boundaries, trust internally
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
```typescript
// Bad: optional chaining because shape is unclear
const value = response?.data?.items?.[0]?.name;
// Good: validate at boundary, trust the types
const parsed = responseSchema.parse(rawResponse);
const value = parsed.data.items[0].name;
```
## Error handling
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
## Keep logic density low
Avoid packing branching, lookup, and transformation into single dense expressions.
```typescript
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(account, rates.find((r) => r.region === account.region));
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);
if (!rate) throw new MissingRateError(account.region);
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
```
## Centralize policy
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
## React: keep components dumb
- Components render state and dispatch events — they don't compute transitions
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
- Never mirror a source of truth into local state; derive from it
- Test state logic as pure functions without rendering
## File organization
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
- Name files after the main export (`create-toolcall.ts`)
- Use `index.ts` as an entrypoint, not a dumping ground
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
## Refactoring contract
Refactoring is structure work, not feature work.
- Preserve behavior by default, especially user-facing behavior
- Do not remove features to simplify code without explicit approval
- Have a verification strategy before you start
- Fully migrate callers and remove old paths in the same refactor
- No fallback behavior by default — prefer explicit error over silent degradation

73
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,73 @@
# Designing Features
How to think through a feature before writing code.
## Start from the user
Even for backend work, start from the user's perspective:
- What problem does this solve?
- What triggers it? User action, schedule, event?
- What does success look like from the user's perspective?
- What data does it need? Where does that data come from?
## Map existing code
Before designing anything new, understand what exists:
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
- What types and data shapes are already defined?
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
## Define verification before implementation
Before designing the solution, define how you'll know it works:
- What tests will prove this feature is correct?
- At what layer? Unit, integration, E2E?
- What's the simplest way to verify the core behavior?
If you can't define verification, you don't understand the feature well enough yet.
## Design the shape
### Data
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
### Layers
- What belongs in each layer?
- Where are the boundaries?
- What does each layer expose to the layer above?
### Interactions
- How does data flow through the system?
- What triggers what?
- Where do side effects happen?
### Refactoring
- What existing code needs to change?
- Is existing code testable enough? If not, that's part of the plan.
## Create a concrete plan
Once the design is clear:
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
3. **What to refactor** before adding new code
4. **How to verify** each step
## Principles
- **Fit, don't force** — new code should fit existing patterns, or refactor first
- **Simple** — the best design is the simplest one that works
- **Verify early** — define how to test before designing the implementation

130
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,130 @@
# Development
## Prerequisites
- Node.js (see `.tool-versions` for exact version)
- npm workspaces (comes with Node)
## Running the dev server
```bash
npm run dev
```
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
### Running alongside the main checkout
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
```bash
PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
## Build sync gotchas
### Relay → Daemon
When changing `packages/relay/src/*`, rebuild before running the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
### Server → CLI
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
## CLI reference
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status
```
Use `--host <host:port>` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
```
## Agent state
Agent data lives at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
Find by content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
```
## Provider session files
Get the session ID from the agent JSON (`persistence.sessionId`), then:
**Claude:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Testing with Playwright MCP
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## Expo troubleshooting
```bash
npx expo-doctor
```
Diagnoses version mismatches and native module issues.
## Typecheck
Always run typecheck after changes:
```bash
npm run typecheck
```

67
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,67 @@
# Release
All workspaces share one version and release together.
## Standard release (patch)
```bash
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
## Manual step-by-step
```bash
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Draft release flow
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
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
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `npm run release:patch` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

123
docs/TESTING.md Normal file
View File

@@ -0,0 +1,123 @@
# Testing
## Philosophy
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
## Test-driven development
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
```
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
```
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
## Determinism first
Tests must produce the same result every run:
- No conditional assertions or branching paths
- No reliance on timing, randomness, or network jitter
- No weak assertions (`toBeTruthy`, `toBeDefined`)
- Assert the full intended behavior, not fragments
```typescript
// Bad: conditional and weak
it("creates a tool call", async () => {
const result = await createToolCall(input);
if (result.ok) {
expect(result.id).toBeDefined();
}
});
// Good: deterministic and explicit
it("returns timeout error when provider times out", async () => {
const result = await createToolCall(input);
expect(result).toEqual({
ok: false,
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
});
});
```
## Flaky tests are a bug
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
## Real dependencies over mocks
Mocks are not the default. They require an explicit decision.
- **Database**: real test database, not a mock
- **APIs**: real APIs with test/sandbox credentials, not request mocks
- **File system**: temporary directory that gets cleaned up, not fs mocks
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
### Use swappable adapters instead
When you need test isolation, design code so dependencies are injectable:
```typescript
interface EmailSender {
send(to: string, body: string): Promise<void>;
}
// Production
const realSender: EmailSender = { send: sendgrid.send };
// Test: in-memory adapter
function createTestEmailSender() {
const sent: Array<{ to: string; body: string }> = [];
return {
send: async (to: string, body: string) => { sent.push({ to, body }); },
sent,
};
}
```
## End-to-end means end-to-end
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
- Extract complex setup into reusable helpers
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
## Agent authentication in tests
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
## Debugging with tests
Use the test as your debugging ground:
1. Add temporary logging to the code under test
2. Run the test, observe actual values
3. Trace the flow end-to-end through test output
4. Confirm each assumption with actual output
5. Remove logging when done
The test output is the source of truth, not your reading of the code.
## Design for testability
If code isn't testable, refactor it. Signs:
- You want to reach for a mock
- You can't inject a dependency
- You need to test private internals
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.

7374
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "paseo",
"version": "0.1.25",
"version": "0.1.31",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/server",
"packages/app",
"packages/relay",
@@ -28,6 +29,7 @@
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
@@ -54,11 +56,12 @@
"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",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",

View File

@@ -100,6 +100,9 @@ export default {
output: "single",
favicon: "./assets/images/favicon.png",
},
autolinking: {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
"expo-router",
[
@@ -143,6 +146,7 @@ export default {
experiments: {
typedRoutes: true,
reactCompiler: true,
autolinkingModuleResolution: true,
},
extra: {
router: {},

Binary file not shown.

View File

@@ -1,183 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectDaemonClient,
createReplyTurn,
expectDetachedFromBottom,
expectNearBottom,
getChatContainerKey,
readScrollMetrics,
scrollUpFromBottom,
seedBottomAnchorAgent,
waitForAgentReady,
waitForContentGrowth,
} from "./helpers/agent-bottom-anchor";
test.describe.configure({ timeout: 180000 });
async function openWorkspaceAgentTab(page: Page, agentId: string) {
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
await expect(tab).toBeVisible({ timeout: 30000 });
await tab.click();
}
test("direct load and refresh land at the bottom for history-backed chats", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-direct-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
await page.reload({ waitUntil: "commit" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("revisiting a loaded chat restores bottom anchoring", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-switch-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
await page.getByTestId("sidebar-new-agent").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30000,
});
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-sticky-${Date.now()}`,
turnCount: 10,
});
await page.setViewportSize({ width: 1320, height: 920 });
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.click();
for (let index = 0; index < 6; index += 1) {
await composer.pressSequentially(`composer growth line ${index + 1}`);
if (index < 5) {
await page.keyboard.press("Shift+Enter");
}
}
await expectNearBottom(page);
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
await page.setViewportSize({ width: 820, height: 760 });
await expectNearBottom(page);
await scrollUpFromBottom(page, 720);
await expectDetachedFromBottom(page);
const beforeExternalUpdate = await readScrollMetrics(page);
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
await client.sendAgentMessage(agent.id, externalTurn.message);
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
const finish = await client.waitForFinish(agent.id, 120000);
expect(finish.status).toBe("idle");
await expectDetachedFromBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
page,
}) => {
await page.addInitScript(() => {
(window as typeof window & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
(window as typeof window & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
});
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-virtualized-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expect
.poll(async () => await getChatContainerKey(page))
.toBe("web-partial-virtualized");
await expectNearBottom(page);
await page.reload({ waitUntil: "commit" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expect
.poll(async () => await getChatContainerKey(page))
.toBe("web-partial-virtualized");
await expectNearBottom(page);
await page.setViewportSize({ width: 780, height: 720 });
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});

View File

@@ -1,35 +0,0 @@
import { test, expect } from "./fixtures";
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
test.setTimeout(120_000);
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
await page.getByTestId("agent-overflow-menu").click();
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
"Not available"
);
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
await expect(
page.getByTestId("agent-details-persistence-session-id-value")
).not.toHaveText("Not available", { timeout: 90_000 });
await page.getByTestId("agent-details-agent-id").click();
await expect(page.getByTestId("app-toast")).toBeVisible();
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,197 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
const SERVER_ID =
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
const AGENT_ID =
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
const DAEMON_ENDPOINT =
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
const NEAR_BOTTOM_THRESHOLD_PX = 64;
type ScrollMetrics = {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
test.use({ browserName: "firefox" });
function seedDaemonRegistryScript(params: {
serverId: string;
endpoint: string;
nowIso: string;
}) {
const daemon = {
serverId: params.serverId,
label: "localhost",
connections: [
{
id: `direct:${params.endpoint}`,
type: "direct",
endpoint: params.endpoint,
},
],
preferredConnectionId: `direct:${params.endpoint}`,
createdAt: params.nowIso,
updatedAt: params.nowIso,
};
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
serverId: params.serverId,
provider: "codex",
providerPreferences: {
claude: { model: "haiku" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
},
})
);
}
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
return {
offsetY,
contentHeight,
viewportHeight,
distanceFromBottom,
};
});
}
async function scrollUpFromBottom(
page: Page,
pixels: number
): Promise<void> {
await page.getByTestId("agent-chat-scroll").evaluate(
(root: Element, amount: number) => {
const rootElement = root as HTMLElement;
const candidates = [
rootElement,
...Array.from(rootElement.querySelectorAll("*")),
];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const bottomOffset = Math.max(
0,
scrollElement.scrollHeight - scrollElement.clientHeight
);
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
},
pixels
);
}
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
page,
}, testInfo) => {
const userAgent = await page.evaluate(() => navigator.userAgent);
expect(userAgent).toContain("Firefox");
await page.addInitScript(seedDaemonRegistryScript, {
serverId: SERVER_ID,
endpoint: DAEMON_ENDPOINT,
nowIso: new Date().toISOString(),
});
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
timeout: 60_000,
});
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
timeout: 60_000,
});
// Require enough history so the repro actually scrolls away from bottom.
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
})
.toBeGreaterThan(300);
await scrollUpFromBottom(page, 900);
await page.waitForTimeout(250);
const beforeSubmit = await readScrollMetrics(page);
const beforePath = testInfo.outputPath("before-submit.png");
await page.screenshot({ path: beforePath, fullPage: true });
await testInfo.attach("before-submit", {
path: beforePath,
contentType: "image/png",
});
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
await page.waitForTimeout(1200);
const afterSubmit = await readScrollMetrics(page);
const afterPath = testInfo.outputPath("after-submit.png");
await page.screenshot({ path: afterPath, fullPage: true });
await testInfo.attach("after-submit", {
path: afterPath,
contentType: "image/png",
});
await testInfo.attach("firefox-scroll-metrics", {
body: JSON.stringify(
{
url: AGENT_URL,
submitText: SUBMIT_TEXT,
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
beforeScreenshot: beforePath,
afterScreenshot: afterPath,
beforeSubmit,
afterSubmit,
},
null,
2
),
contentType: "application/json",
});
console.log(
`[firefox-scroll-repro] ${JSON.stringify(
{
submitText: SUBMIT_TEXT,
beforeSubmit,
afterSubmit,
},
null,
2
)}`
);
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
NEAR_BOTTOM_THRESHOLD_PX
);
});

View File

@@ -1,27 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,440 +0,0 @@
import path from 'node:path';
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ mode: 'serial', timeout: 120000 });
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
if (await primaryBackdrop.isVisible().catch(() => false)) {
await primaryBackdrop.click({ force: true });
await expect(primaryBackdrop).toHaveCount(0);
}
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
if (await overflowBackdrop.isVisible().catch(() => false)) {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
if (!(await modeToggle.isVisible().catch(() => false))) {
return;
}
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
const menu = page.getByTestId('changes-diff-status-menu');
await expect(menu).toBeVisible({ timeout: 10000 });
const optionTestId =
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
await page.getByTestId(optionTestId).click({ force: true });
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-primary-cta-caret').first();
await expect(caret).toBeVisible();
await caret.click();
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) {
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
return;
}
const changesScope = getChangesScope(page);
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
timeout: 30000,
});
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
timeout: 30000,
});
}
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
if (!(await stopButton.isVisible().catch(() => false))) {
return;
}
await expect(stopButton).not.toBeVisible({ timeout });
}
async function createAgentAndWait(page: Page, message: string) {
await createAgent(page, message);
}
async function selectAttachWorktree(page: Page, branchName: string) {
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
await trigger.click();
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(branchName);
}
const preferredOption = menu
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
.first();
await expect(preferredOption).toBeVisible({ timeout: 10000 });
await preferredOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText(branchName, { timeout: 30000 });
}
async function enableCreateWorktree(page: Page) {
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
if (/Create new worktree/i.test(currentValue)) {
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
return;
}
await trigger.click();
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
await expect(createOption).toBeVisible({ timeout: 10000 });
await createOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
}
async function refreshUncommittedMode(page: Page) {
await selectChangesView(page, 'base');
await selectChangesView(page, 'working');
}
async function refreshChangesTab(page: Page) {
await ensureExplorerTabsVisible(page);
await page.getByTestId('explorer-tab-files').first().click();
await page.getByTestId('explorer-tab-changes').first().click();
}
function normalizeTmpPath(value: string) {
if (value.startsWith('/var/')) {
return `/private${value}`;
}
return value;
}
type GitWorktreeEntry = {
worktreePath: string;
branchRef: string | null;
};
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
const blocks = raw
.split(/\n\s*\n/g)
.map((block) => block.trim())
.filter((block) => block.length > 0);
const entries: GitWorktreeEntry[] = [];
for (const block of blocks) {
const worktreeMatch = block.match(/^worktree (.+)$/m);
if (!worktreeMatch) {
continue;
}
const branchMatch = block.match(/^branch (.+)$/m);
entries.push({
worktreePath: worktreeMatch[1].trim(),
branchRef: branchMatch ? branchMatch[1].trim() : null,
});
}
return entries;
}
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
const normalizedRepoPath = normalizeTmpPath(repoPath);
while (Date.now() < deadline) {
try {
const output = execSync('git worktree list --porcelain', {
cwd: repoPath,
encoding: 'utf8',
});
const entries = parseGitWorktreeList(output);
const candidate = entries.find((entry) => {
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
if (normalizedWorktreePath === normalizedRepoPath) {
return false;
}
if (!entry.branchRef) {
return false;
}
return !/\/main$/i.test(entry.branchRef);
});
if (candidate) {
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
if (branchName) {
return {
worktreePath: candidate.worktreePath,
branchName,
};
}
}
} catch {
// Ignore transient git worktree read errors while polling.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
}
test('checkout-first Changes panel ship loop', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await enableCreateWorktree(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
await expect
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
.not.toBe('Unknown');
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
repo.path
);
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
realpath(repo.path).catch(() => repo.path),
]);
const normalizedRepo = normalizeTmpPath(resolvedRepo);
const normalizedCwd = normalizeTmpPath(resolvedCwd);
const expectedMarker = `${path.sep}worktrees${path.sep}`;
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n');
await refreshUncommittedMode(page);
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toContainText('Commit');
await primaryCta.click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push once from the menu so the branch has an origin/<branch> ref.
await openChangesPrimaryMenu(page);
await page.getByTestId('changes-menu-push').click();
await expect
.poll(() => {
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
return true;
} catch {
return false;
}
}, { timeout: 30000 })
.toBe(true);
const notesPath = path.join(firstCwd, 'notes.txt');
await writeFile(notesPath, 'Second change\n');
await refreshUncommittedMode(page);
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('changes-primary-cta').click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
await pushPrimary.click();
// Regression check: the primary CTA stays in place while pushing.
await expect(pushPrimary).toBeVisible();
await page.waitForTimeout(50);
await expect(pushPrimary).toBeVisible();
await expect
.poll(() => {
try {
const count = execSync(
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
{ cwd: firstCwd, encoding: 'utf8' }
).trim();
return Number.parseInt(count, 10);
} catch {
return null;
}
}, { timeout: 30000 })
.toBe(0);
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
execSync("git checkout main", { cwd: repo.path });
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
execSync("git push", { cwd: repo.path });
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
'Committed',
{ timeout: 30000 }
);
await refreshChangesTab(page);
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
// primary flow or hidden behind menu variants), so continue from a fresh draft.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();
}
});

View File

@@ -1,27 +0,0 @@
import { test, expect } from './fixtures';
import { createAgentInRepo } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await createAgentInRepo(page, { directory: repo.path, prompt });
// Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
// Verify we used the seeded fast model (do not fall back to other defaults).
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
await expect(modelPicker).toBeVisible({ timeout: 30000 });
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
// Verify the assistant response is rendered.
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,19 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('daemon is connected in settings', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await gotoHome(page);
await openSettings(page);
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,64 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('deleting an agent persists after reload', async ({ page }) => {
const repo = await createTempGitRepo();
const nonce = Math.random().toString(36).slice(2, 10);
const prompt = `respond-ready-${nonce}`;
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Create agent (via message input) so it shows up in the sidebar list.
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
timeout: 30000,
});
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
const match =
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]);
const agentId = decodeURIComponent(match[2]);
// Return home and delete via long-press in the agent list.
await gotoHome(page);
const rowTestId = `agent-row-${serverId}-${agentId}`;
const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 });
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true });
const confirmArchive = page
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
.first();
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
await confirmArchive.click({ force: true });
// Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
// A full reload should not bring the agent back.
await page.reload();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,342 +0,0 @@
import { test, expect } from './fixtures';
import {
createAgent,
createAgentInRepo,
ensureHostSelected,
gotoHome,
openSettings,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from '@/utils/host-routes';
import {
ensureWorkspaceAgentPaneVisible,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function addFakeMicrophone(page: Page) {
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
const base64Audio = (await readFile(fixturePath)).toString('base64');
const mimeType = 'audio/wav';
return page.addInitScript(({ base64Audio, mimeType }) => {
const mic = {
active: 0,
getUserMediaCalls: 0,
stopCalls: 0,
lastRecorder: null as null | { state: string },
};
(window as any).__mic = mic;
(window as any).isSecureContext = true;
const nav = navigator as any;
if (!nav.mediaDevices) {
nav.mediaDevices = {};
}
nav.mediaDevices.getUserMedia = async () => {
mic.getUserMediaCalls += 1;
mic.active += 1;
const track = {
stop: () => {
mic.stopCalls += 1;
mic.active = Math.max(0, mic.active - 1);
},
};
return {
getTracks: () => [track],
};
};
const AudioContextCtor =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
const nativeCreateMediaStreamSource =
AudioContextCtor.prototype.createMediaStreamSource;
AudioContextCtor.prototype.createMediaStreamSource =
function patchedCreateMediaStreamSource(stream: unknown) {
const isFakeStream =
!!stream &&
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
typeof (stream as { id?: unknown }).id === 'undefined';
if (isFakeStream) {
throw new Error('Force recorder fallback for fake microphone stream');
}
return nativeCreateMediaStreamSource.call(this, stream);
};
}
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
};
class FakeMediaRecorder extends EventTarget {
public static isTypeSupported() {
return true;
}
public state: 'inactive' | 'recording' = 'inactive';
public mimeType: string;
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
public onerror: ((event: unknown) => void) | null = null;
constructor(_stream: unknown, options?: MediaRecorderOptions) {
super();
this.mimeType = options?.mimeType ?? 'audio/webm';
mic.lastRecorder = this;
}
public start() {
this.state = 'recording';
}
public stop() {
if (this.state !== 'recording') {
throw new Error('Not recording');
}
this.state = 'inactive';
try {
this.ondataavailable?.({
data: blobFromBase64(base64Audio, mimeType),
});
} catch (err) {
this.onerror?.(err);
}
this.dispatchEvent(new Event('stop'));
}
}
(window as any).MediaRecorder = FakeMediaRecorder;
}, { base64Audio, mimeType });
}
async function expectComposerReady(page: Page) {
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
}
async function expectDictationStarted(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
}
async function expectDictationStopped(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
}
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await page.getByTestId('workspace-new-agent-tab').first().click();
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await openSettings(page);
await page.keyboard.press('Control+d');
await page.waitForTimeout(200);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
const active = await page.evaluate(() => (window as any).__mic.active as number);
expect(calls).toBe(0);
expect(active).toBe(0);
} finally {
await repo.cleanup();
}
});
test('dictation transcribes fixture via real STT', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
await expectDictationStopped(page);
await expect
.poll(
async () => page.getByRole('button', { name: 'Copy message' }).count(),
{ timeout: 60_000 }
)
.toBeGreaterThan(initialCopyMessageCount);
} finally {
await repo.cleanup();
}
});
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
await page.evaluate(() => {
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
if (mic.lastRecorder) {
mic.lastRecorder.state = 'inactive';
}
});
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
if (!match) {
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]!);
const agentId = decodeURIComponent(match[2]!);
await expectComposerReady(page);
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
await page.keyboard.press('Control+d');
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
await page.waitForTimeout(10_000);
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
await expect(page).toHaveURL(
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
);
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,42 +0,0 @@
import { test, expect } from "./fixtures";
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("draft enables explorer after selecting a working directory", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
try {
await gotoHome(page);
await ensureHostSelected(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
const toggle = page
.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
})
.first();
await expect(toggle).toBeVisible({ timeout: 30000 });
await toggle.click();
await expect(
page.locator('[data-testid="explorer-header"]:visible').first()
).toBeVisible({ timeout: 30000 });
const terminalsTab = page
.locator('[data-testid="explorer-tab-terminals"]:visible')
.first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(
page.locator('[data-testid="terminal-surface"]:visible').first()
).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,4 +1,8 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
@@ -58,31 +62,12 @@ test.beforeEach(async ({ page }) => {
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
}
const testDaemon = {
const testDaemon = buildSeededHost({
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
// Ensure create flow never uses a remembered host from the developer's real app.
serverId: testDaemon.serverId,
// Keep e2e fast/cheap by default.
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {

View File

@@ -1,119 +0,0 @@
import path from 'node:path';
import { appendFile } from 'node:fs/promises';
import { test, expect, type Page } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ timeout: 90000 });
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function openChangesPanel(page: Page) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesScope(page).getByTestId('changes-header');
if (!(await changesHeader.isVisible())) {
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible();
}
async function refreshUncommittedMode(page: Page) {
const scope = getChangesScope(page);
const toggle = scope.getByTestId('changes-diff-status').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const currentLabel = (await toggle.innerText()).trim();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(firstTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
const nextLabel = (await toggle.innerText()).trim();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(secondTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
}
async function createAgentAndWait(page: Page, message: string) {
await createAgent(page, message);
}
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-sticky-');
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await openChangesPanel(page);
const readmePath = path.join(repo.path, 'README.md');
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
await appendFile(readmePath, `\n${lines}`);
await refreshUncommittedMode(page);
const scope = getChangesScope(page);
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
await fileToggle.click();
const markerLine = scope.getByText('Sticky header line 250').first();
await expect(markerLine).toBeVisible({ timeout: 30000 });
const scroll = scope.getByTestId('git-diff-scroll').first();
await expect(scroll).toBeVisible();
await expect.poll(async () => {
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
}).toBe(true);
await scroll.hover();
for (let i = 0; i < 12; i++) {
await page.mouse.wheel(0, 700);
}
await expect.poll(async () => {
return await scroll.evaluate((el) => el.scrollTop ?? 0);
}).toBeGreaterThan(0);
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
await expect(fileToggle).toBeVisible();
} finally {
await repo.cleanup();
}
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from './fixtures'
import { createTempGitRepo } from './helpers/workspace'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
const prompt = `global draft prompt ${Date.now()}`
try {
await gotoHome(page)
await ensureHostSelected(page)
await setWorkingDirectory(page, repo.path)
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
const providerSelector = page.getByTestId('agent-provider-selector').first()
const modeSelector = page.getByTestId('agent-mode-selector').first()
const modelSelector = page.getByTestId('agent-model-selector').first()
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(prompt)
await composer.press('Enter')
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
} finally {
await repo.cleanup()
}
})

View File

@@ -1,219 +0,0 @@
import { test, expect } from './fixtures'
import type { Page } from '@playwright/test'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
import { createTempGitRepo } from './helpers/workspace'
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
const url = new URL(rawUrl)
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
const workspaceToken = pathMatch?.[1] ?? null
let agentId = pathMatch?.[2] ?? null
if (!agentId) {
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
if (openMatch?.[1]) {
agentId = openMatch[1]
}
}
return { workspaceToken, agentId }
}
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
const url = new URL(rawUrl)
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
return openMatch?.[1] ?? null
}
async function preferSlowerThinkingOption(page: Page): Promise<void> {
await page.keyboard.press('Escape').catch(() => undefined)
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return
}
await thinkingTrigger.click({ force: true })
const menu = page.getByTestId('agent-thinking-menu').first()
if (!(await menu.isVisible().catch(() => false))) {
return
}
const preferred = ['high', 'max', 'deep', 'long', 'medium']
for (const label of preferred) {
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true })
await expect(menu).not.toBeVisible({ timeout: 5_000 })
return
}
}
const options = menu.getByRole('button')
const count = await options.count()
if (count > 0) {
await options.last().click({ force: true })
}
await expect(menu).not.toBeVisible({ timeout: 5_000 })
}
async function isAnyStopVisible(page: Page): Promise<boolean> {
const candidates = page.getByRole('button', { name: /stop|cancel/i })
const count = await candidates.count()
for (let index = 0; index < count; index += 1) {
if (await candidates.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function isRunningControlVisible(page: Page): Promise<boolean> {
if (await isAnyStopVisible(page)) {
return true
}
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
const count = await interruptSendButton.count()
for (let index = 0; index < count; index += 1) {
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
const locator = page.getByText(matcher).first()
const matches = page.getByText(matcher)
const count = await matches.count()
for (let index = 0; index < count; index += 1) {
if (await matches.nth(index).isVisible().catch(() => false)) {
return true
}
}
return await locator.isVisible().catch(() => false)
}
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
page,
}) => {
test.setTimeout(360_000)
const serverId = process.env.E2E_SERVER_ID
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.')
}
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
const id = `${Date.now()}`
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
const seedToken = `ACK_SEED_${id}`
const secondToken = `ACK_SECOND_${id}`
expect(seedPrompt).not.toBe(secondPrompt)
expect(seedToken).not.toBe(secondToken)
try {
await gotoHome(page)
await ensureHostSelected(page)
await page.goto(`/h/${serverId}/new-agent`)
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
// 1) Seed workspace with an existing agent.
await setWorkingDirectory(page, repo.path)
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await seedComposer.fill(seedPrompt)
await seedComposer.press('Enter')
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const seededContext = parseWorkspaceContextFromUrl(page.url())
if (!seededContext.workspaceToken || !seededContext.agentId) {
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
}
const seededAgentId = seededContext.agentId
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
// 2) Use global New Agent entry and 3) select same workspace.
await page.getByText('New agent', { exact: true }).first().click()
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
await setWorkingDirectory(page, repo.path)
await preferSlowerThinkingOption(page)
await page.keyboard.press('Escape').catch(() => undefined)
// 4) Create second agent from global draft.
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(secondPrompt)
await composer.press('Enter')
await page.waitForTimeout(500)
if (page.url().includes('/new-agent')) {
const sendButton = page.getByRole('button', { name: /send message/i }).first()
await expect(sendButton).toBeVisible({ timeout: 10_000 })
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
await sendButton.click({ force: true })
}
// 5) Assert redirect workspace context + created agent tab is active/open.
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const createdContext = parseWorkspaceContextFromUrl(page.url())
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
if (!createdContext.agentId) {
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
}
const createdAgentId = createdContext.agentId
expect(createdAgentId).toBeTruthy()
expect(createdAgentId).not.toBe(seededAgentId)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
await expect
.poll(async () => {
const finalTabIds = await getWorkspaceTabTestIds(page)
return finalTabIds.includes(createdTabTestId)
})
.toBe(true)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
// 6) Response assertions are scoped to created-agent active context.
await expect
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
.toBe(true)
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
// 7) Lifecycle assertions on created agent only: running first, then idle.
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await lifecycleComposer.fill(lifecyclePrompt)
await lifecycleComposer.press('Enter')
await expect
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
.toBe(true)
let sawRunning = false
for (let attempt = 0; attempt < 1200; attempt += 1) {
if (await isRunningControlVisible(page)) {
sawRunning = true
break
}
await page.waitForTimeout(50)
}
expect(sawRunning).toBe(true)
await expect
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
.toBe(true)
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
} finally {
await repo.cleanup()
}
})

View File

@@ -199,6 +199,19 @@ function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
if (existsSync(relayDistEntry)) {
return;
}
console.log('[e2e] Building @getpaseo/relay for daemon startup');
execSync('npm run build --workspace=@getpaseo/relay', {
cwd: repoRoot,
stdio: 'inherit',
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const idx = url.indexOf(marker);
@@ -217,6 +230,7 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test');
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });

View File

@@ -66,17 +66,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
}).join("\n");
}
function buildProtocolMessage(label: string): string {
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label),
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string): string {
return ["REPLY:", buildReplyBlock(label)].join("\n");
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
@@ -124,9 +124,11 @@ export async function seedBottomAnchorAgent(input: {
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
@@ -134,7 +136,7 @@ export async function seedBottomAnchorAgent(input: {
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
@@ -143,11 +145,11 @@ export async function seedBottomAnchorAgent(input: {
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
@@ -165,16 +167,29 @@ export async function seedBottomAnchorAgent(input: {
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
return getVisibleChatScroll(page).evaluate((root: Element) => {
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
.filter((element): element is HTMLElement => element instanceof HTMLElement)
.filter((element) => {
const tagName = element.tagName.toLowerCase();
const isEditable =
tagName === "textarea" ||
tagName === "input" ||
element.getAttribute("contenteditable") === "true";
return !isEditable && element.scrollHeight - element.clientHeight > 1;
});
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
candidates.sort(
(left, right) =>
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
)[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
@@ -194,29 +209,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
await page.getByTestId("agent-chat-scroll").evaluate(
(root: Element, amount: number) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const bottomOffset = Math.max(
0,
scrollElement.scrollHeight - scrollElement.clientHeight
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
})
);
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
},
pixels
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
@@ -263,9 +282,7 @@ export async function waitForContentGrowth(
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return page
.getByTestId("agent-chat-scroll")
.evaluate((element) => {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;

View File

@@ -1,4 +1,8 @@
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -33,8 +37,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
} catch {
return true;
}
@@ -45,42 +49,20 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ expectedEndpoint, nowIso, expectedServerId }) => {
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem(
'@paseo:daemon-registry',
JSON.stringify([
{
serverId: expectedServerId,
label: 'localhost',
connections: [
{
id: `direct:${expectedEndpoint}`,
type: 'direct',
endpoint: expectedEndpoint,
},
],
preferredConnectionId: `direct:${expectedEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
},
])
);
localStorage.setItem(
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: expectedServerId,
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
})
);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ expectedEndpoint, nowIso, expectedServerId }
{ daemon, preferences }
);
await page.reload();
@@ -128,13 +110,13 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
);
}
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
}
@@ -154,14 +136,36 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
}
}
export const gotoHome = async (page: Page) => {
export const gotoAppShell = async (page: Page) => {
await page.goto('/');
await ensureE2EStorageSeeded(page);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole('textbox', { name: 'Message agent...' });
if (!(await composer.first().isVisible().catch(() => false))) {
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
const newAgentButton = page.getByText('New agent', { exact: true }).first();
await newAgentButton.click();
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 }
)
.toBe(true);
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
}
await expect(composer.first()).toBeVisible({ timeout: 30000 });
};

View File

@@ -0,0 +1,39 @@
export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
return {
id: `direct:${endpoint}`,
type: "directTcp" as const,
endpoint,
};
}
export function buildSeededHost(input: {
serverId: string;
endpoint: string;
label?: string;
nowIso: string;
}) {
const connection = buildDirectTcpConnection(input.endpoint);
return {
serverId: input.serverId,
label: input.label ?? TEST_HOST_LABEL,
connections: [connection],
preferredConnectionId: connection.id,
createdAt: input.nowIso,
updatedAt: input.nowIso,
};
}
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}

View File

@@ -1,44 +0,0 @@
import { test, expect } from './fixtures';
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
});
await page.goto('/');
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
await page.getByText('Direct connection', { exact: true }).click();
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
// First-time connection prompts for an optional label.
const nameModal = page.getByTestId('name-host-modal');
const showedNameModal = await nameModal
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (showedNameModal) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
timeout: 15000,
});
});

View File

@@ -1,119 +0,0 @@
import { test, expect } from './fixtures';
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const seededServerId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!seededServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const extraPort = Number(daemonPort) + 1;
const extraEndpoint = `127.0.0.1:${extraPort}`;
const nowIso = new Date().toISOString();
const extraDaemon = {
serverId: 'srv_e2e_extra_daemon',
label: 'extra',
connections: [
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
],
preferredConnectionId: `direct:${extraEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seededTestDaemon = {
serverId: seededServerId,
label: 'localhost',
connections: [
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
// Add a second host once (fixtures seed the primary host on every navigation).
await page.addInitScript(
({ daemon, seededTestDaemon, seedOnceKey }) => {
if (localStorage.getItem(seedOnceKey)) {
return;
}
const raw = localStorage.getItem('@paseo:daemon-registry');
let parsed: any[] = [];
if (raw) {
try {
parsed = JSON.parse(raw);
} catch {
parsed = [];
}
}
const list = Array.isArray(parsed) ? parsed : [];
localStorage.setItem(seedOnceKey, '1');
const next = [...list];
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
if (!hasSeeded) {
next.push(seededTestDaemon);
}
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
if (!alreadyPresent) {
next.push(daemon);
}
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
},
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
);
await page.goto('/settings');
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
await hostSettingsButton.click();
const hostDetailModal = page.getByTestId('host-detail-modal');
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
await hostDetailModal.getByText('Advanced', { exact: true }).click();
await page.getByText('Remove host', { exact: true }).last().click();
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click();
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction(
(serverId) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
} catch {
return false;
}
},
extraDaemon.serverId,
{ timeout: 10000 }
);
// Prevent the fixture from overwriting storage on reload; verify persistence.
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
});
await page.reload();
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
});

View File

@@ -1,124 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome } from './helpers/app';
test('new agent auto-selects the previous host', async ({ page }) => {
await gotoHome(page);
await ensureHostSelected(page);
await gotoHome(page);
// The selected host should be restored after a full reload without manual selection.
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent respects serverId in the URL', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Ensure this test's storage is deterministic even under parallel load.
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
serverId: testDaemon.serverId,
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
await page.goto('/settings');
await page.evaluate(
({ daemon, preferences }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon, preferences: createAgentPreferences }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
if (await newAgentButton.isVisible().catch(() => false)) {
await newAgentButton.click();
} else {
await page.getByText('New agent', { exact: true }).first().click();
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
await gotoHome(page);
await page.evaluate(
({ daemon }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
// Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});

View File

@@ -1,17 +0,0 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
await gotoHome(page);
await page.getByTestId("menu-button").first().focus();
await page.keyboard.press("Shift+/");
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 });
await expect(content).toContainText("Show keyboard shortcuts");
await expect(content).toContainText("Toggle left sidebar");
});

View File

@@ -1,85 +0,0 @@
import { test, expect } from './fixtures';
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Override the default fixture seeding for this navigation (must run before app boot).
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/settings');
await expect
.poll(
async () => {
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
return 'welcome';
}
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
return 'settings';
}
return '';
},
{ timeout: 15000 }
)
.not.toBe('');
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
if (isWelcome) {
await page.getByText('Direct connection', { exact: true }).first().click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click();
}
const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
const nameModal = page.getByTestId('name-host-modal');
if (await nameModal.isVisible().catch(() => false)) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 10000 });
await settingsButton.click();
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
timeout: 15000,
});
await page.waitForFunction(
({ port, serverId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
return (
entry?.serverId === serverId &&
Array.isArray(entry?.connections) &&
entry.connections.some(
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
)
);
} catch {
return false;
}
},
{ port: daemonPort, serverId },
{ timeout: 10000 }
);
});

View File

@@ -1,91 +0,0 @@
import { test, expect } from './fixtures';
import { Buffer } from 'node:buffer';
import { gotoHome, openSettings } from './helpers/app';
function encodeBase64Url(input: string): string {
return Buffer.from(input, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
// Override the default fixture seeding for this test.
await gotoHome(page);
await openSettings(page);
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/');
const relayEndpoint = `127.0.0.1:${relayPort}`;
const offer = {
v: 2 as const,
serverId,
daemonPublicKeyB64,
relay: { endpoint: relayEndpoint },
};
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
if (await welcomeTitle.isVisible().catch(() => false)) {
await page.getByTestId('welcome-paste-pairing-link').click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Paste pairing link', { exact: true }).click();
}
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
await input.fill(offerUrl);
await page.getByTestId('pair-link-submit').click();
const nameHostModal = page.getByTestId('name-host-modal');
if (await nameHostModal.isVisible().catch(() => false)) {
await nameHostModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
await page.waitForFunction(
({ expected }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
const relayId = `relay:${expected.relay.endpoint}`;
return (
entry?.serverId === expected.serverId &&
Array.isArray(entry?.connections) &&
entry.connections.length === 1 &&
entry.connections[0]?.id === relayId &&
entry.connections[0]?.type === 'relay' &&
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
);
} catch {
return false;
}
},
{ expected: offer },
{ timeout: 10000 }
);
});

View File

@@ -1,53 +0,0 @@
import { test, expect } from "./fixtures";
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("pastes clipboard image into prompt attachments", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable();
await input.focus();
const result = await page.evaluate(() => {
const active = document.activeElement;
if (!(active instanceof HTMLTextAreaElement)) {
return {
pasted: false,
elementTag: active ? active.tagName : null,
defaultPrevented: false,
};
}
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
type: "image/png",
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
const event = new ClipboardEvent("paste", {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
});
active.dispatchEvent(event);
return {
pasted: true,
elementTag: active.tagName,
defaultPrevented: event.defaultPrevented,
};
});
expect(result.pasted).toBe(true);
expect(result.defaultPrevented).toBe(true);
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,92 +0,0 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test, expect } from './fixtures';
import {
createAgentWithConfig,
waitForPermissionPrompt,
allowPermission,
denyPermission,
waitForAgentFinishUI,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
const FILE_CONTENT = 'Hello from permission test';
function buildWriteCommand(filePath: string): string {
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
}
test.describe('permission prompts', () => {
test('allow permission creates the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-allow-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
provider: 'claude',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await allowPermission(page);
// Wait for file to be created
await expect
.poll(() => existsSync(filePath), {
message: `File ${filePath} should exist after allowing permission`,
timeout: 30000,
})
.toBe(true);
// After allowing, the file should be created successfully
// The tool call count might still be 1 if the UI updates quickly
const fileContent = await readFile(filePath, 'utf-8');
expect(fileContent.trim()).toBe(FILE_CONTENT);
} finally {
await repo.cleanup();
}
});
test('deny permission does not create the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-deny-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
provider: 'claude',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await denyPermission(page);
await waitForAgentFinishUI(page, 30000);
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
expect(existsSync(filePath)).toBe(false);
} finally {
await repo.cleanup();
}
});
});

View File

@@ -1,25 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
const promptText = `Test prompt that should be preserved ${Date.now()}`;
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, nonExistentDir);
// Enter prompt text
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(promptText);
// Try to submit - this should fail with an error about the directory not existing
await input.press('Enter');
// Verify error message is displayed (error includes the path)
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
// Verify the prompt text is still preserved in the input
await expect(input).toHaveValue(promptText);
});

View File

@@ -1,44 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Override the default fixture seeding for this test.
await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
// Should eventually connect through the relay connection.
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
});

View File

@@ -1,62 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('relay connection stays stable across multiple tabs', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Use relay by making the direct endpoint intentionally fail.
await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
const page2 = await page.context().newPage();
await page2.route(/:(6767)\b/, (route) => route.abort());
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
});
await page2.goto('/');
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
await settingsButton2.click();
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Stability window: keep both tabs open and ensure they remain online.
await page.waitForTimeout(30_000);
await expect(card.getByText('Online', { exact: true })).toBeVisible();
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,39 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
const repoA = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repoA.path);
await createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//);
// Click sidebar New Agent and assert it re-opens the host draft route while
// preserving working directory context from the selected agent.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
const searchWorkingDir = await page.evaluate(() => {
try {
return new URL(window.location.href).searchParams.get('workingDir');
} catch {
return null;
}
});
const normalizedCandidates = new Set<string>([repoA.path]);
if (repoA.path.startsWith('/var/')) {
normalizedCandidates.add(`/private${repoA.path}`);
}
if (repoA.path.startsWith('/private/var/')) {
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
}
expect(searchWorkingDir).not.toBeNull();
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
} finally {
await repoA.cleanup();
}
});

View File

@@ -1,117 +0,0 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
await gotoHome(page);
const trigger = page.getByText("Project", { exact: true }).first();
await expect(trigger).toBeVisible();
await page.evaluate(() => {
(window as any).__projectFilterFlashProbe = new Promise<{
targetFound: boolean;
visibleAtOrigin: boolean;
records: Array<{ left: number; top: number; opacity: number }>;
}>((resolve) => {
let target: HTMLElement | null = null;
const records: Array<{ left: number; top: number; opacity: number }> = [];
const capture = () => {
if (!target) return;
const style = getComputedStyle(target);
records.push({
left: Number.parseFloat(style.left || "0"),
top: Number.parseFloat(style.top || "0"),
opacity: Number.parseFloat(style.opacity || "1"),
});
};
const tryResolveTarget = (root: HTMLElement) => {
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const element of stack) {
if (element.dataset?.testid === "combobox-desktop-container") {
target = element;
return true;
}
}
return false;
};
const finish = () => {
observer.disconnect();
if (!target) {
resolve({
targetFound: false,
visibleAtOrigin: false,
records: [],
});
return;
}
const visibleAtOrigin = records.some(
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
);
resolve({
targetFound: true,
visibleAtOrigin,
records,
});
};
const sampleFrames = () => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
finish();
});
});
});
});
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of Array.from(mutation.addedNodes)) {
if (!(added instanceof HTMLElement)) continue;
if (tryResolveTarget(added)) {
sampleFrames();
return;
}
}
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLElement &&
!target &&
tryResolveTarget(mutation.target)
) {
sampleFrames();
return;
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style"],
});
setTimeout(() => finish(), 2500);
});
});
await trigger.click();
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
expect(probe.targetFound).toBe(true);
expect(probe.visibleAtOrigin).toBe(false);
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
await gotoHome(page);
await openSettings(page);
const menuButton = page.getByRole('button', { name: /menu/i }).first();
await expect(menuButton).toBeVisible();
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
await menuButton.focus();
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();
// Tooltip should also appear on hover.
await menuButton.blur();
await expect(tooltip).toHaveCount(0);
await menuButton.hover();
await expect(tooltip).toBeVisible();
const triggerBox = await menuButton.boundingBox();
const tooltipBox = await tooltip.boundingBox();
expect(triggerBox).not.toBeNull();
expect(tooltipBox).not.toBeNull();
if (!triggerBox || !tooltipBox) return;
// side=right => tooltip starts to the right of the trigger.
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
// Keep it reasonably close (should be ~trigger.right + offset).
const expectedX = triggerBox.x + triggerBox.width + 8;
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
expect(tooltipBox.width).toBeLessThanOrEqual(500);
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
expect(tooltipBox.height).toBeLessThanOrEqual(80);
// align=center => centers should be roughly aligned (allow some clamping tolerance).
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,238 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import {
openNewAgentComposer,
seedWorkspaceActivity,
switchWorkspaceViaSidebar,
} from "./helpers/workspace-ui";
function percentile(values: number[], p: number): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const rank = Math.ceil((p / 100) * sorted.length);
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
return sorted[index] ?? 0;
}
function summarize(values: number[]) {
if (values.length === 0) {
return {
count: 0,
minMs: 0,
maxMs: 0,
avgMs: 0,
p95Ms: 0,
p99Ms: 0,
};
}
const sum = values.reduce((acc, value) => acc + value, 0);
return {
count: values.length,
minMs: Math.min(...values),
maxMs: Math.max(...values),
avgMs: Math.round((sum / values.length) * 100) / 100,
p95Ms: percentile(values, 95),
p99Ms: percentile(values, 99),
};
}
function buildStressCommand(doneMarker: string): string {
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
const markerFile = ".paseo-terminal-benchmark-marker";
return [
"i=1",
"printf '\\033[?1049h\\033[2J'",
"while [ $i -le 240 ]; do",
"printf '\\033[H'",
"r=1",
"while [ $r -le 24 ]; do",
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
"r=$((r+1))",
"done",
"sleep 0.01",
"i=$((i+1))",
"done",
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
`printf '${doneMarker}\\n' > '${markerFile}'`,
].join("; ");
}
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
try {
const text = await readFile(filePath, "utf8");
return text.includes(marker);
} catch {
return false;
}
}
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
const expected = currentlyExpanded ? "false" : "true";
const start = Date.now();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
return Date.now() - start;
}
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await openNewAgentComposer(page);
await setWorkingDirectory(page, repo.path);
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30_000,
});
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
await newTerminalButton.click();
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
await expect(surface).toBeVisible({ timeout: 60_000 });
await surface.click({ force: true });
await page.evaluate(() => {
const monitor = {
samples: [] as number[],
active: true,
rafId: 0,
lastTs: performance.now(),
};
const tick = (ts: number) => {
if (!monitor.active) {
return;
}
monitor.samples.push(ts - monitor.lastTs);
monitor.lastTs = ts;
monitor.rafId = requestAnimationFrame(tick);
};
monitor.rafId = requestAnimationFrame(tick);
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
{
stop: () => {
if (!monitor.active) {
return { samples: monitor.samples };
}
monitor.active = false;
cancelAnimationFrame(monitor.rafId);
return { samples: monitor.samples };
},
};
});
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
const stressCommand = buildStressCommand(doneMarker);
await page.keyboard.type(stressCommand, { delay: 0 });
await page.keyboard.press("Enter");
const interactionLatenciesMs: number[] = [];
for (let attempt = 0; attempt < 18; attempt += 1) {
const latency = await toggleExplorerAndMeasureLatency(page);
interactionLatenciesMs.push(latency);
}
const rafResult = await page.evaluate(() => {
const handle = (
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
).__PASEO_E2E_RAF_MONITOR__;
if (!handle || typeof handle.stop !== "function") {
return { samples: [] as number[] };
}
return handle.stop();
});
await surface.click({ force: true });
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
await page.keyboard.press("Enter");
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
timeout: 120_000,
}).toBe(true);
const diagnostics = await page.evaluate(async () => {
const debug = (
window as {
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
consumeReports?: () => Promise<unknown[]>;
};
}
).__PASEO_PERF_DIAGNOSTICS_DEBUG__;
if (!debug || typeof debug.consumeReports !== "function") {
return { available: false, reports: [] as unknown[] };
}
try {
const reports = await debug.consumeReports();
return { available: true, reports: Array.isArray(reports) ? reports : [] };
} catch (error) {
return {
available: true,
reports: [] as unknown[],
error: error instanceof Error ? error.message : String(error),
};
}
});
const frameGapsMs = (rafResult.samples ?? []).filter(
(sample) => Number.isFinite(sample) && sample > 0
);
const report = {
mode: "report-only",
profile: "single-stress",
generatedAt: new Date().toISOString(),
workload: {
frames: 240,
rowsPerFrame: 24,
frameSleepMs: 10,
doneMarker,
postMarker,
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
},
frameGapMs: {
...summarize(frameGapsMs),
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
},
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
diagnostics: {
available: diagnostics.available,
reportCount: diagnostics.reports.length,
reports: diagnostics.reports,
error: "error" in diagnostics ? diagnostics.error : undefined,
},
};
await testInfo.attach("terminal-responsiveness-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,158 +0,0 @@
import { expect, test } from "./fixtures";
import { gotoHome } from "./helpers/app";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
await gotoHome(page);
const workingDirectorySelect = page
.locator('[data-testid="working-directory-select"]:visible')
.first();
await expect(workingDirectorySelect).toBeVisible();
await workingDirectorySelect.click({ force: true });
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
await expect(searchInput).toBeVisible();
await page.evaluate(() => {
const trigger = document.querySelector('[data-testid="working-directory-select"]');
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
if (!(trigger instanceof HTMLElement)) {
throw new Error("Missing working-directory-select trigger.");
}
if (!(searchInput instanceof HTMLInputElement)) {
throw new Error("Missing working directory search input.");
}
if (!(container instanceof HTMLElement)) {
throw new Error("Missing combobox desktop container.");
}
const state = {
samples: 0,
underTriggerSamples: 0,
emptyWhileSearchingSamples: 0,
minSearchDelta: Number.POSITIVE_INFINITY,
maxSearchDelta: Number.NEGATIVE_INFINITY,
minContainerDelta: Number.POSITIVE_INFINITY,
maxContainerDelta: Number.NEGATIVE_INFINITY,
logs: [] as Array<{
reason: string;
query: string;
searchDelta: number;
containerDelta: number;
hasEmpty: boolean;
containerTop: number;
containerBottom: number;
triggerTop: number;
searchBottom: number;
}>,
};
const sample = (reason: string) => {
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
return;
}
const query = searchInput.value.trim();
if (!query) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const searchRect = searchInput.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
const searchDelta = searchRect.bottom - triggerRect.top;
const containerDelta = containerRect.bottom - triggerRect.top;
state.samples += 1;
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
if (searchDelta > 2 || containerDelta > 2) {
state.underTriggerSamples += 1;
}
if (hasEmpty) {
state.emptyWhileSearchingSamples += 1;
}
if (state.logs.length < 80) {
state.logs.push({
reason,
query,
searchDelta,
containerDelta,
hasEmpty,
containerTop: containerRect.top,
containerBottom: containerRect.bottom,
triggerTop: triggerRect.top,
searchBottom: searchRect.bottom,
});
}
};
const mutationObserver = new MutationObserver(() => sample("mutation"));
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
const resizeObserver = new ResizeObserver(() => sample("resize"));
resizeObserver.observe(container);
resizeObserver.observe(searchInput);
let rafId = 0;
const loop = () => {
sample("raf");
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
(window as any).__paseoComboboxObserver = {
stop: () => {
cancelAnimationFrame(rafId);
mutationObserver.disconnect();
resizeObserver.disconnect();
sample("stop");
return {
...state,
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
};
},
};
});
const queries = [
"/tmp/paseo-stability-a",
"/tmp/paseo-stability-ab",
"/tmp/paseo-stability-abc",
"/tmp/paseo-stability-longer-branch",
"/tmp/paseo-stability-z",
];
for (const query of queries) {
await searchInput.fill("");
await searchInput.type(query, { delay: 20 });
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
await expect(customOption).toBeVisible();
await page.waitForTimeout(100);
}
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
const debug = JSON.stringify(stats.logs.slice(-10));
expect(stats.samples, debug).toBeGreaterThan(20);
expect(stats.underTriggerSamples, debug).toBe(0);
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
});

View File

@@ -1,107 +0,0 @@
import { test, expect } from './fixtures'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
import { createTempGitRepo } from './helpers/workspace'
import {
ensureWorkspaceAgentPaneVisible,
getWorkspaceTabTestIds,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs'
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.')
}
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
const seedPrompt = `seed workspace ${Date.now()}`
const createPrompt = `workspace draft prompt ${Date.now()}`
try {
await gotoHome(page)
await ensureHostSelected(page)
// Force the setup onto the global draft surface. In slow runs the app can
// still be on a previously opened agent/workspace view where the placement
// form is not rendered.
await page.goto(`/h/${serverId}/new-agent`)
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
await setWorkingDirectory(page, repo.path)
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await seedComposer.fill(seedPrompt)
await page.getByRole('button', { name: /send message/i }).first().click()
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
})
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
expect(workspaceRouteToken).toBeTruthy()
await waitForWorkspaceTabsVisible(page)
await ensureWorkspaceAgentPaneVisible(page)
const beforeDraftIds = await getWorkspaceTabTestIds(page)
await page.getByTestId('workspace-new-agent-tab').first().click()
await ensureWorkspaceAgentPaneVisible(page)
const withDraftIds = await getWorkspaceTabTestIds(page)
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
if (!draftTabTestId) {
throw new Error('Expected a draft workspace tab to be created.')
}
const draftId = draftTabTestId.replace('workspace-tab-', '')
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
const providerSelector = page.getByTestId('agent-provider-selector').first()
const modeSelector = page.getByTestId('agent-mode-selector').first()
const modelSelector = page.getByTestId('agent-model-selector').first()
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(createPrompt)
await composer.press('Enter')
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
const finalIds = await getWorkspaceTabTestIds(page)
expect(finalIds).toContain(draftTabTestId)
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
const currentUrl = page.url()
if (!workspaceRouteToken) {
throw new Error('Expected workspace route token to be present.')
}
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
} finally {
await repo.cleanup()
}
})

View File

@@ -1,125 +0,0 @@
import { test, expect } from "./fixtures";
import type { Page } from "@playwright/test";
import { createAgentInRepo } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import {
ensureWorkspaceAgentPaneVisible,
getWorkspaceTabTestIds,
sampleWorkspaceTabIds,
waitForWorkspaceTabsVisible,
} from "./helpers/workspace-tabs";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
async function expectComposerFocused(page: Page) {
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect
.poll(async () => {
return await composer.evaluate(
(element) => document.activeElement === element
);
})
.toBe(true);
}
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
const seedPrompt = `seed prompt ${Date.now()}`;
const createPrompt = `retarget prompt ${Date.now()}`;
try {
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
const beforeDraftIds = await getWorkspaceTabTestIds(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await ensureWorkspaceAgentPaneVisible(page);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
const withDraftIds = await getWorkspaceTabTestIds(page);
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
expect(draftTabTestId).toBeTruthy();
const draftId = draftTabTestId!.replace("workspace-tab-", "");
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill(createPrompt);
await input.press("Enter");
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const snapshots = await samplingPromise;
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
expect(maxObservedCount).toBe(withDraftIds.length);
const finalIds = await getWorkspaceTabTestIds(page);
expect(finalIds.length).toBe(withDraftIds.length);
expect(finalIds).toContain(draftTabTestId!);
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
} finally {
await repo.cleanup();
}
});
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
const firstPrompt = `first tab prompt ${Date.now()}`;
const secondPrompt = `second tab prompt ${Date.now()}`;
try {
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expectComposerFocused(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(secondPrompt);
await composer.press("Enter");
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
const secondAgentTabTestId = withSecondAgentIds.find(
(id) => !beforeSecondAgentIds.includes(id)
);
if (!secondAgentTabTestId) {
throw new Error("Expected second agent tab to be created.");
}
const firstAgentTabTestId = beforeSecondAgentIds[0];
if (!firstAgentTabTestId) {
throw new Error("Expected first agent tab to exist.");
}
await page.getByTestId(firstAgentTabTestId).first().click();
await expectComposerFocused(page);
await page.getByTestId(secondAgentTabTestId).first().click();
await expectComposerFocused(page);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,165 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { createAgentInRepo } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await createAgentInRepo(page, {
directory: workspacePath,
prompt: `workspace header restore ${Date.now()}`,
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
}
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
try {
await openWorkspaceWithAgent(page, repo.path);
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(terminalButton).toBeVisible({ timeout: 30000 });
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
const workspaceTabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
);
const initialTabCount = await workspaceTabs.count();
const targetTabCount = initialTabCount + 8;
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
await terminalButton.click();
await expect
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(attempt + 1);
}
const agentBoundsBefore = await agentButton.boundingBox();
const terminalBoundsBefore = await terminalButton.boundingBox();
const viewport = page.viewportSize();
expect(agentBoundsBefore).not.toBeNull();
expect(terminalBoundsBefore).not.toBeNull();
expect(viewport).not.toBeNull();
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
return;
}
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
await tabsScroll.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
});
await page.waitForTimeout(200);
const agentBoundsAfter = await agentButton.boundingBox();
const terminalBoundsAfter = await terminalButton.boundingBox();
expect(agentBoundsAfter).not.toBeNull();
expect(terminalBoundsAfter).not.toBeNull();
if (!agentBoundsAfter || !terminalBoundsAfter) {
return;
}
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
} finally {
await repo.cleanup();
}
});
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
try {
await openWorkspaceWithAgent(page, repo.path);
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
const workspaceTabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
);
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
const lastTabBounds = await workspaceTabs.last().boundingBox();
const agentBounds = await agentButton.boundingBox();
expect(lastTabBounds).not.toBeNull();
expect(agentBounds).not.toBeNull();
if (!lastTabBounds || !agentBounds) {
return;
}
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
expect(horizontalGap).toBeGreaterThanOrEqual(0);
expect(horizontalGap).toBeLessThanOrEqual(24);
} finally {
await repo.cleanup();
}
});
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
try {
await openWorkspaceWithAgent(page, repo.path);
const toggle = page.getByTestId("workspace-explorer-toggle").first();
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
if (initiallyExpanded) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
}
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,58 +0,0 @@
import { execSync } from 'node:child_process';
import { test } from './fixtures';
import { setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import {
expectWorkspaceHeader,
openNewAgentComposer,
seedWorkspaceActivity,
switchWorkspaceViaSidebar,
workspaceLabelFromPath,
} from './helpers/workspace-ui';
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
const tokenA = `SYNC_A_${Date.now()}`;
const tokenB = `SYNC_B_${Date.now()}`;
try {
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
await openNewAgentComposer(page);
await setWorkingDirectory(page, repoA.path);
await seedWorkspaceActivity(page, tokenA);
await openNewAgentComposer(page);
await setWorkingDirectory(page, repoB.path);
await seedWorkspaceActivity(page, tokenB);
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expectWorkspaceHeader(page, {
title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path),
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
await expectWorkspaceHeader(page, {
title: 'sync-b-branch',
subtitle: workspaceLabelFromPath(repoB.path),
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expectWorkspaceHeader(page, {
title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path),
});
} finally {
await repoA.cleanup();
await repoB.cleanup();
}
});

View File

@@ -2,6 +2,10 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";

View File

@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
@@ -14,6 +15,11 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
const escapedAppSrcRoot = appSrcRoot
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
.join("[\\\\/]");
const pathSeparatorPattern = "[\\\\/]";
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
@@ -22,6 +28,9 @@ config.resolver.extraNodeModules = {
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
};
config.resolver.blockList = new RegExp(
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
);
function isLocalModuleImport(moduleName) {
return (

View File

@@ -1,21 +1,21 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.25",
"version": "0.1.31",
"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:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"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",
"android:release": "npm run android:production",
"android:clean": "expo prebuild --platform android --clean --non-interactive",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"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",
@@ -23,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"
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@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.25",
"@getpaseo/expo-two-way-audio": "0.1.31",
"@getpaseo/server": "0.1.31",
"@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",
@@ -52,7 +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",
"@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

@@ -0,0 +1,36 @@
export const WELCOME_ROUTE = '/welcome'
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
}): boolean {
if (input.onlineServerId) {
return false
}
if (input.pathname === WELCOME_ROUTE) {
return false
}
if (input.hasTimedOut) {
return false
}
return input.isDesktopStartupRace || input.daemonCount > 0
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false
}
if (input.pathname !== '/' && input.pathname !== '') {
return false
}
return input.isDesktopStartupRace || input.daemonCount > 0
}

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";
@@ -12,15 +18,30 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
useRef,
} from "react";
import { Platform } from "react-native";
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";
@@ -32,7 +53,7 @@ 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";
@@ -43,27 +64,19 @@ 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 { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
}
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -71,28 +84,54 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
void ensureOsNotificationPermission();
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 Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
const route = buildNotificationRoute(customEvent.detail?.data);
event.preventDefault();
router.push(route as any);
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
@@ -141,35 +180,105 @@ function PushNotificationRouter() {
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
if (!client) {
return null;
}
return (
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
{null}
</SessionProvider>
);
}
function HostSessionManager() {
const hosts = useHosts();
if (hosts.length === 0) {
return null;
}
return (
<>
{hosts.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
</>
);
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const store = getHostRuntimeStore();
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
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;
chromeEnabled?: boolean;
}
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
function AppContainer({
children,
selectedAgentId,
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
const { theme } = useUnistyles();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
const daemons = useHosts();
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 = daemons.length > 0;
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
enabled: chromeEnabled,
@@ -178,6 +287,50 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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,
@@ -186,18 +339,14 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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];
@@ -211,26 +360,19 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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();
}
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.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(
@@ -242,15 +384,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
})
.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;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
@@ -269,44 +403,24 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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>
);
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -325,7 +439,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
{children}
</VoiceProvider>
);
@@ -372,9 +488,22 @@ 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
@@ -393,10 +522,40 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
}, [params.open, pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
<AppContainer
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
chromeEnabled={shouldShowAppChrome}
>
{children}
</AppContainer>
);
}
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
@@ -453,55 +612,55 @@ export default function RootLayout() {
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<PerfDiagnosticsProvider scope="root_layout">
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<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" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonRegistryProvider>
</QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
options={{ freezeOnBlur: true }}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false, freezeOnBlur: true }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</PerfDiagnosticsProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</GestureHandlerRootView>
);
}

View File

@@ -1,7 +1,7 @@
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,
@@ -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;

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,44 @@
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { useCallback } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
parseHostWorkspaceRouteFromPathname,
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
} from '@/utils/host-routes'
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 router = useRouter()
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
}>()
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
const workspaceValue = Array.isArray(params.workspaceId)
? params.workspaceId[0]
: params.workspaceId
const serverId = serverValue?.trim() ?? ''
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
const openIntent = parseWorkspaceOpenIntent(openValue)
const handleOpenIntentConsumed = useCallback(
function handleOpenIntentConsumed() {
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any)
},
[router, serverId, workspaceId]
)
return (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
onOpenIntentConsumed={handleOpenIntentConsumed}
/>
)
}

View File

@@ -1,65 +1,113 @@
import { useEffect, useMemo } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-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'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from '@/app-support/index-startup'
const STARTUP_TIMEOUT_MS = 30_000
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore()
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
}
}
return firstOnlineServerId
},
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
}
}
return firstOnlineServerId
}
)
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
}, [params.serverId]);
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
return () => {
clearTimeout(timer)
}
if (requestedServerId) {
const requested = daemons.find(
(daemon) => daemon.serverId === requestedServerId
);
if (requested) {
return requested.serverId;
}
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId, requestedServerId]);
}, [])
useEffect(() => {
if (registryLoading || preferencesLoading) {
return;
if (!onlineServerId) {
return
}
if (!targetServerId) {
return;
if (pathname !== '/' && pathname !== '') {
return
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(targetServerId) as any);
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
if (registryLoading || preferencesLoading) {
return <StartupSplashScreen />;
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />
}
if (!targetServerId) {
if (isReconciling) {
return <StartupSplashScreen />;
}
return <WelcomeScreen />;
if (!onlineServerId) {
return <WelcomeScreen />
}
return null;
return null
}

View File

@@ -5,11 +5,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { probeConnection } from "@/utils/test-daemon-connection";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
@@ -151,7 +151,8 @@ export default function PairScanScreen() {
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
@@ -241,7 +242,7 @@ export default function PairScanScreen() {
return;
}
await probeConnection(
const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -250,6 +251,7 @@ export default function PairScanScreen() {
},
{ serverId: offer.serverId },
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
@@ -311,7 +313,7 @@ export default function PairScanScreen() {
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void updateHost(serverId, { label }).finally(() => {
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});

View File

@@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
@@ -29,16 +29,16 @@ export default function LegacySettingsRoute() {
}, [daemons, preferences.serverId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]);
}, [preferencesLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
if (preferencesLoading) {
return (
<View
style={{

View File

@@ -0,0 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
export default function WelcomeRoute() {
return <WelcomeScreen />
}

View File

@@ -0,0 +1,14 @@
import { afterEach, describe, expect, it } from "vitest";
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
describe("attachment store", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("creates the default web attachment store without runtime module resolution errors", async () => {
const store = await getAttachmentStore();
expect(store.storageType).toBe("web-indexeddb");
});
});

View File

@@ -1,26 +1,26 @@
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()) {
const { createDesktopAttachmentStore } = require(
"@/desktop/attachments/desktop-attachment-store"
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = require(
"@/attachments/web/indexeddb-attachment-store"
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = require(
"@/attachments/native/native-file-attachment-store"
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
return createNativeFileAttachmentStore();
}

View File

@@ -2,9 +2,10 @@ import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link2 } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, probeConnection } from "@/utils/test-daemon-connection";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -129,7 +130,8 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
const { theme } = useUnistyles();
const { daemons, upsertDirectConnection } = useDaemonRegistry();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -179,11 +181,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true);
setErrorMessage("");
const { serverId, hostname } = await probeConnection({
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
});
await client.close().catch(() => undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);

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,9 +28,10 @@ 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,
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
@@ -96,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))
@@ -390,16 +394,24 @@ export function AgentInputArea({
async function handlePickImage() {
const result = await pickImages()
if (!result?.assets?.length) {
if (!result?.length) {
return
}
const newImages = await Promise.all(
result.assets.map(async (asset) => {
result.map(async (pickedImage) => {
if (pickedImage.source.kind === 'blob') {
return await persistAttachmentFromBlob({
blob: pickedImage.source.blob,
mimeType: pickedImage.mimeType || 'image/jpeg',
fileName: pickedImage.fileName ?? null,
})
}
return await persistAttachmentFromFileUri({
uri: asset.uri,
mimeType: asset.mimeType || 'image/jpeg',
fileName: asset.fileName ?? null,
uri: pickedImage.source.uri,
mimeType: pickedImage.mimeType || 'image/jpeg',
fileName: pickedImage.fileName ?? null,
})
})
)
@@ -690,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}>
@@ -711,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}>
@@ -876,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',
@@ -889,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',
},
@@ -902,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,13 @@ 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, type Href } 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 { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
interface AgentListProps {
@@ -251,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'
@@ -271,22 +269,13 @@ export function AgentList({
const serverId = agent.serverId
const agentId = agent.id
const navigationKey = buildAgentNavigationKey(serverId, agentId)
startNavigationTiming(navigationKey, {
from: 'home',
to: 'agent',
params: { serverId, agentId },
})
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)
router.navigate(route)
},
[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";
@@ -67,17 +66,18 @@ import {
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
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) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -91,6 +91,7 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
@@ -101,6 +102,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
onOpenWorkspaceFile,
}, ref) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
@@ -116,7 +118,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[isMobile]
);
const [isNearBottom, setIsNearBottom] = useState(true);
const streamItemCountRef = useRef(0);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -159,12 +160,17 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
const normalized = normalizeInlinePath(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ filePath: normalized.file });
return;
}
const route = buildHostWorkspaceFileRoute(
resolvedServerId,
workspaceId,
@@ -194,6 +200,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
resolvedServerId,
router,
setExplorerTabForCheckout,
onOpenWorkspaceFile,
workspaceId,
]
);
@@ -312,6 +319,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
/>
);
@@ -462,74 +470,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[pendingPermissions, agentId]
);
useEffect(() => {
if (!isPerfLoggingEnabled()) {
return;
}
const totalCount = streamItems.length;
const prevCount = streamItemCountRef.current;
if (totalCount === prevCount) {
return;
}
const delta = Math.abs(totalCount - prevCount);
streamItemCountRef.current = totalCount;
if (
totalCount < STREAM_ITEM_LOG_MIN_COUNT &&
delta < STREAM_ITEM_LOG_DELTA_THRESHOLD
) {
return;
}
let userCount = 0;
let assistantCount = 0;
let toolCallCount = 0;
let thoughtCount = 0;
let activityCount = 0;
let todoCount = 0;
for (const item of streamItems) {
switch (item.kind) {
case "user_message":
userCount += 1;
break;
case "assistant_message":
assistantCount += 1;
break;
case "tool_call":
toolCallCount += 1;
break;
case "thought":
thoughtCount += 1;
break;
case "activity_log":
activityCount += 1;
break;
case "todo_list":
todoCount += 1;
break;
default:
break;
}
}
const metrics =
totalCount >= STREAM_ITEM_LOG_MIN_COUNT
? measurePayload(streamItems)
: null;
perfLog(AGENT_STREAM_LOG_TAG, {
event: "stream_items",
agentId,
totalCount,
userCount,
assistantCount,
toolCallCount,
thoughtCount,
activityCount,
todoCount,
pendingPermissionCount: pendingPermissionItems.length,
streamHeadCount: streamHead?.length ?? 0,
payloadApproxBytes: metrics?.approxBytes ?? 0,
payloadFieldCount: metrics?.fieldCount ?? 0,
});
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
@@ -722,147 +662,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
});
function normalizeInlinePath(
rawPath: string,
cwd?: string
): { directory: string; file?: string } | null {
if (!rawPath) {
return null;
}
const normalizedInput = normalizePathInput(rawPath);
if (!normalizedInput) {
return null;
}
let normalized = normalizedInput;
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
if (cwdRelative) {
normalized = cwdRelative;
}
if (normalized.startsWith("./")) {
normalized = normalized.slice(2) || ".";
}
if (!normalized.length) {
normalized = ".";
}
if (normalized === ".") {
return { directory: "." };
}
if (normalized.endsWith("/")) {
const dir = normalized.replace(/\/+$/, "");
return { directory: dir.length > 0 ? dir : "." };
}
const lastSlash = normalized.lastIndexOf("/");
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
return {
directory: directory.length > 0 ? directory : ".",
file: normalized,
};
}
function normalizePathInput(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
}
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
return null;
}
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
const comparePath = normalizePathForCompare(pathValue);
const compareCwd = normalizePathForCompare(normalizedCwdBase);
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
const comparePrefix = normalizePathForCompare(prefix);
if (comparePath === compareCwd) {
return ".";
}
if (comparePath.startsWith(comparePrefix)) {
return pathValue.slice(prefix.length) || ".";
}
return null;
}
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}
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

@@ -6,7 +6,6 @@ import {
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -154,16 +153,6 @@ const styles = StyleSheet.create((theme) => ({
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log(
"[ArtifactDrawer] Showing artifact:",
artifact.id,
artifact.type,
artifact.title
);
}, [artifact]);
if (!artifact) {
return null;
}

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

@@ -69,7 +69,6 @@ export function DictationControls({
<VolumeMeter
volume={volume}
isMuted={false}
isDetecting
isSpeaking={false}
orientation="horizontal"
/>
@@ -177,7 +176,6 @@ export function DictationOverlay({
<VolumeMeter
volume={volume}
isMuted={false}
isDetecting
isSpeaking={false}
orientation="horizontal"
color={theme.colors.palette.white}

View File

@@ -23,13 +23,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerSidebar] ${event}`, details);
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
}
interface ExplorerSidebarProps {
@@ -485,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 {
@@ -41,7 +42,7 @@ import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
@@ -105,7 +106,7 @@ export function FileExplorerPane({
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
@@ -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}>
@@ -838,7 +838,7 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
@@ -875,11 +875,11 @@ 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,
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surfaceSidebar,
},
paneHeaderLeft: {
flex: 1,
@@ -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

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
describe("git-action-visibility", () => {
describe("shouldShowMergeFromBaseAction", () => {
it("shows on non-base branches", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: false,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(true);
});
it("hides on base branch when no remote exists", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("hides on base branch when local is in sync with origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("shows on base branch when ahead of origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 0,
})
).toBe(true);
});
it("shows on base branch when behind origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
})
).toBe(true);
});
});
});

View File

@@ -1,14 +0,0 @@
export function shouldShowMergeFromBaseAction(input: {
isOnBaseBranch: boolean;
hasRemote: boolean;
aheadOfOrigin: number;
behindOfOrigin: number;
}): boolean {
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
runtime: {
commit: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-from-base": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
},
},
...overrides,
};
}
describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => {
const noPrActions = buildGitActions(createInput());
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
);
expect(noPrActions.primary).toBeNull();
expect(withPrActions.primary?.id).toBe("push");
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
});
it("disables hidden-before actions with explanations instead", () => {
const actions = buildGitActions(createInput());
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
expect(actionById.get("push")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.get("pr")).toMatchObject({
label: "Create PR",
disabled: true,
description: "Branch has no commits ahead of main",
});
expect(actionById.get("merge-branch")).toMatchObject({
disabled: true,
description: "No commits to merge into main",
});
expect(actionById.get("merge-from-base")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.has("archive-worktree")).toBe(false);
});
it("keeps the current primary action visible in the menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
);
expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(syncAction).toMatchObject({
label: "Sync",
disabled: true,
description: "Already up to date",
});
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
});

View File

@@ -0,0 +1,256 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "push"
| "pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
interface GitActionRuntimeState {
disabled: boolean;
status: ActionStatus;
icon?: ReactElement;
handler: () => void;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
handler: input.runtime.commit.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote,
status: input.runtime.push.status,
description: input.hasRemote ? undefined : "No remote configured",
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled:
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
status: input.runtime["merge-from-base"].status,
description: getMergeFromBaseDescription(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
status: input.runtime["archive-worktree"].status,
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
}
return { primary, secondary, menu: [] };
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
}
if (input.aheadOfOrigin > 0 && input.hasRemote) {
return "push";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
return false;
}
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable";
}
if (input.aheadCount === 0) {
return `Branch has no commits ahead of ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (input.aheadCount === 0) {
return `No commits to merge into ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (!input.isOnBaseBranch) {
return undefined;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
}
return undefined;
}

View File

@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
closeOnSelect={
action.status === "idle" &&
action.id === "pr" &&
action.label === "View PR"
}
description={action.description}
onSelect={action.handler}
>

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,8 +38,8 @@ 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 { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import {
DropdownMenu,
@@ -47,30 +48,26 @@ 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,
type GitActions,
} from "@/components/git-actions-policy";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
// Re-export types from shared hook
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
const DIFF_FILE_LOG_LINE_THRESHOLD = 500;
const DIFF_FILE_LOG_TOKEN_THRESHOLD = 5000;
type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
@@ -158,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={[
@@ -169,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}
@@ -199,81 +205,20 @@ const DiffFileHeader = memo(function DiffFileHeader({
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null);
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
let totalLines = 0;
let totalTokens = 0;
for (const hunk of file.hunks) {
totalLines += hunk.lines.length;
for (const line of hunk.lines) {
if (line.tokens) {
totalTokens += line.tokens.length;
}
}
}
return {
hunkCount: file.hunks.length,
lineCount: totalLines,
tokenCount: totalTokens,
};
}, [file]);
const shouldLogFileMetrics =
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD;
const toggleExpanded = useCallback(() => {
pressHandledRef.current = true;
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
expandStartRef.current = getNowMs();
perfLog(DIFF_FILE_LOG_TAG, {
event: "toggle",
path: file.path,
nextExpanded: !isExpanded,
hunkCount,
lineCount,
tokenCount,
});
}
onToggle(file.path);
}, [file.path, onToggle, isExpanded, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
useEffect(() => {
if (!isPerfLoggingEnabled() || !shouldLogFileMetrics) {
return;
}
const startMs = expandStartRef.current;
if (startMs === null) {
return;
}
expandStartRef.current = null;
const logCommit = () => {
const durationMs = getNowMs() - startMs;
perfLog(DIFF_FILE_LOG_TAG, {
event: isExpanded ? "expand_commit" : "collapse_commit",
path: file.path,
durationMs: Math.round(durationMs),
hunkCount,
lineCount,
tokenCount,
});
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => logCommit());
} else {
logCommit();
}
}, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
}, [file.path, onToggle]);
return (
<View
style={[
styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder,
isExpanded && styles.fileSectionHeaderExpanded,
]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
@@ -346,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;
}) {
@@ -398,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>
);
}
@@ -456,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;
@@ -506,30 +516,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const diffMetrics = useMemo(() => {
let hunkCount = 0;
let lineCount = 0;
let tokenCount = 0;
for (const file of files) {
hunkCount += file.hunks.length;
for (const hunk of file.hunks) {
lineCount += hunk.lines.length;
for (const line of hunk.lines) {
if (line.tokens) {
tokenCount += line.tokens.length;
}
}
}
}
return {
fileCount: files.length,
hunkCount,
lineCount,
tokenCount,
};
}, [files]);
const lastMetricsKeyRef = useRef<string | null>(null);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
void refreshDiff();
@@ -712,28 +698,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
setDiffModeOverride(null);
}, [autoDiffMode]);
useEffect(() => {
if (!isPerfLoggingEnabled()) {
return;
}
const metricsKey = `${diffMetrics.fileCount}:${diffMetrics.hunkCount}:${diffMetrics.lineCount}:${diffMetrics.tokenCount}`;
if (lastMetricsKeyRef.current === metricsKey) {
return;
}
lastMetricsKeyRef.current = metricsKey;
perfLog(DIFF_PANE_LOG_TAG, {
event: "files_snapshot",
serverId,
workspaceId: workspaceId ?? cwd,
fileCount: diffMetrics.fileCount,
hunkCount: diffMetrics.hunkCount,
lineCount: diffMetrics.lineCount,
tokenCount: diffMetrics.tokenCount,
isLoading: isDiffLoading,
isFetching: isDiffFetching,
});
}, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);
@@ -848,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(
@@ -903,19 +868,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
@@ -1005,184 +964,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
// ==========================================================================
const gitActions: GitActions = useMemo(() => {
if (!isGit) {
return { primary: null, secondary: [], menu: [] };
}
// Build all possible actions
const allActions = new Map<GitActionId, GitAction>();
// Commit - always available
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
return buildGitActions({
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
push: {
disabled: pushDisabled,
status: pushStatus,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
},
},
"merge-branch": {
disabled: mergeDisabled,
status: mergeStatus,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
},
},
});
// Push - when has remote
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
});
}
// View PR - when PR exists
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => openURLInNewTab(prUrl),
});
}
// Create PR - when ahead of base and no PR
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: handleCreatePr,
});
}
// Merge branch - when ahead of base
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
});
}
// Update/sync from base
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
});
}
// Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
// Rule 0: Post-ship in worktree -> Archive
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
}
// Rule 1: Uncommitted changes → Commit
else if (hasUncommittedChanges) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
else if (hasPullRequest) {
primaryActionId = "view-pr";
}
// Rule 4: On base branch -> surface sync explicitly
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
}
// Rule 5: Ahead of base → Ship action based on preference
else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
}
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
// Secondary actions: ship-related + merge from base + push (excluding primary)
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
// Menu actions: none for now (all actionable items are in primary/secondary)
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
@@ -1253,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>
@@ -1322,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",
@@ -1359,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: {
@@ -1426,6 +1315,8 @@ const styles = StyleSheet.create((theme) => ({
},
fileSectionHeaderContainer: {
overflow: "hidden",
},
fileSectionHeaderExpanded: {
backgroundColor: theme.colors.surface1,
},
fileSectionBodyContainer: {
@@ -1444,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,
},
@@ -1522,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,
@@ -1566,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

@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
interface SourceControlPanelIconProps {
size?: number;
color?: string;
strokeWidth?: number;
}
export function SourceControlPanelIcon({
size = 16,
color = "currentColor",
strokeWidth = 2,
}: SourceControlPanelIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
height={18}
rx={2}
stroke={color}
strokeWidth={2}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
</Svg>
);
}

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,16 +22,18 @@ 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 { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
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 { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
import {
@@ -29,23 +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
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return
}
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
}
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'
@@ -53,7 +104,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const { daemons } = useDaemonRegistry()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
@@ -115,31 +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 {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false)
const handleRefresh = useCallback(() => {
@@ -147,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
@@ -178,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
@@ -186,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) => {
@@ -210,83 +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
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
})
.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
runOnJS(logLeftSidebarCloseGesture)('end', {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
})
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 }],
@@ -297,147 +441,174 @@ 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
isOpen={isOpen}
serverId={activeServerId}
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"
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
}
@@ -451,19 +622,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleOpenProjectDesktop}
testID="sidebar-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>
</>
)}
@@ -471,20 +642,21 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</View>
</View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
@@ -496,30 +668,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"
@@ -528,7 +710,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsDesktop}
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
@@ -602,7 +784,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: {},
@@ -623,12 +806,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,
@@ -698,4 +878,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

@@ -101,7 +101,6 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30
const MAX_INPUT_HEIGHT = 160
const IS_WEB = Platform.OS === 'web'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -125,13 +124,10 @@ type TextAreaHandle = {
}
function logWebStickyBottom(
event: string,
details: Record<string, unknown>
_event: string,
_details: Record<string, unknown>
): void {
if (!IS_DEV || !IS_WEB) {
return
}
console.log('[WebStickyBottom]', event, details)
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
@@ -216,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()
@@ -407,6 +404,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
const showOverlay = showDictationOverlay || showRealtimeOverlay
useEffect(() => {
if (isDictating || isDictationProcessing) {
return
@@ -947,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>
@@ -975,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}>
@@ -1013,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}>
@@ -1037,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}>
@@ -1070,10 +1077,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
/>
) : showRealtimeOverlay && voice ? (
<RealtimeVoiceOverlay
volume={voice.volume}
isMuted={voice.isMuted}
isDetecting={voice.isDetecting}
isSpeaking={voice.isSpeaking}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {
@@ -1162,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,
@@ -1180,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',
@@ -1206,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',

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