Compare commits

...

242 Commits

Author SHA1 Message Date
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
Mohamed Boudra
c37684b246 Preserve entitlements when re-signing managed runtime binaries
The sign script was re-signing Mach-O executables with --force and
hardened runtime but without --entitlements, stripping entitlements
like allow-jit that Node.js needs for V8. This caused SIGTRAP on
any Mac where the binary went through Gatekeeper validation.

Extract existing entitlements before re-signing and pass them back
via --entitlements so they are preserved.
2026-03-11 11:36:21 +07:00
Mohamed Boudra
e2068e3d72 chore(release): cut 0.1.25 2026-03-11 11:09:56 +07:00
Mohamed Boudra
443eb16e67 Notarize macOS DMG to fix quarantine on bundled binaries
Tauri notarizes the .app but not the .dmg container. When users
download the DMG from GitHub Releases, macOS quarantines everything
and Gatekeeper doesn't clear quarantine on embedded helper binaries
(like the bundled Node runtime), causing SIGTRAP on first launch.

Add a post-build step that signs, notarizes, and staples the DMG,
then re-uploads it to the release.
2026-03-11 11:09:41 +07:00
Mohamed Boudra
240dc26013 Restore AppImage bundle for Linux (revert deb workaround)
The linuxdeploy failure was caused by CUDA shared library references in
onnxruntime-node, not by linuxdeploy itself. The CUDA stripping step
added in the previous commit fixes the root cause, so AppImage bundling
should work now.
2026-03-11 09:07:44 +07:00
Mohamed Boudra
6b07555a46 Switch Linux bundle from appimage to deb
linuxdeploy-plugin-appimage's "continuous" release on GitHub is broken,
causing every AppImage build to fail. The downloaded binary is actually
an HTML error page. Switch to deb format which doesn't depend on
linuxdeploy at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:21:09 +07:00
Mohamed Boudra
b69bd5271b Fix Linux AppImage build: add APPIMAGE_EXTRACT_AND_RUN=1
linuxdeploy is an AppImage itself and needs FUSE to run. GitHub Actions
runners don't always have working FUSE support. Setting this env var
tells AppImage tools to extract-and-run instead, avoiding the FUSE
dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:07:48 +07:00
Mohamed Boudra
cf4cae2c7d Fix Linux AppImage: strip CUDA deps from onnxruntime binaries
linuxdeploy scans all ELF binaries in the AppDir and fails when it
can't find libcublasLt.so.12 (a CUDA library referenced by the
onnxruntime native module). Use patchelf to remove these optional
CUDA dependencies since we only need CPU inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:59:36 +07:00
Mohamed Boudra
d51f18a2f7 Add workflow step to strip CUDA providers before Linux AppImage build
The build script fix only applies to future tags. For v0.1.24 (and any
tag built before that fix), we need the workflow itself to remove the
CUDA .so files after building the managed runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:54 +07:00
Mohamed Boudra
006db65f08 Remove CUDA/TensorRT providers from onnxruntime-node in managed runtime
linuxdeploy scans all ELF files in the AppDir and fails when it finds
libonnxruntime_providers_cuda.so which links to libcublasLt.so.12 — a
CUDA library not available on CI runners.

onnxruntime falls back to the CPU provider when CUDA is absent, so
removing these has no functional impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:32 +07:00
Mohamed Boudra
f21221c1e1 Add libfuse2 to Linux AppImage build dependencies
linuxdeploy is distributed as an AppImage and may need libfuse2 to
execute even with APPIMAGE_EXTRACT_AND_RUN=1. ubuntu-22.04 runners
don't have it by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:26:11 +07:00
Mohamed Boudra
9faa88e13b Add --verbose to Linux AppImage build for debugging
Need to see the actual linuxdeploy error output instead of the opaque
"failed to run linuxdeploy" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:16:34 +07:00
Mohamed Boudra
faf1eed0ab Fix Linux AppImage build: pin ubuntu-22.04 and disable strip
ubuntu-latest switched to 24.04 which has libraries with .relr.dyn
sections that linuxdeploy's bundled eu-strip cannot handle, causing
consistent "failed to run linuxdeploy" errors.

Pin to ubuntu-22.04 (also better glibc compat for AppImage) and set
NO_STRIP=true as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:06:38 +07:00
Mohamed Boudra
8fc37eac52 Update CHANGELOG for v0.1.24
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:24:06 +07:00
Mohamed Boudra
9e76d1c2d6 Use --no-bundle for desktop smoke builds
Smoke tags have non-numeric pre-release identifiers (e.g. gha-smoke.1)
which MSI bundler rejects. Since smoke builds only need to prove Rust
compilation succeeds, skip bundling entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:01:49 +07:00
Mohamed Boudra
5609c89517 Simplify desktop release pipeline: single build, no process smoke test
Replace the 855-line managed-daemon-smoke.mjs (which spawned relay
servers, daemons, and tested E2E connectivity in CI) with a fast
validate-managed-runtime.mjs that checks the bundle is correctly
assembled without launching any processes.

Structural changes:
- Eliminate double-build: removed the pre-build step that compiled the
  Tauri app just for smoke testing before tauri-action rebuilt it
- Move version-setting before the build so there's no version confusion
- Sign managed runtime before tauri-action build (macOS)
- Smoke tags now do a real tauri build instead of --no-bundle, giving
  actual signal about whether the release would succeed
- Reduce each platform job from ~15 steps to ~12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:43:06 +07:00
Mohamed Boudra
4e4a751921 Improve command center keyboard navigation and new tab shortcut 2026-03-10 20:32:22 +07:00
Mohamed Boudra
6b4978b428 Clean up Windows smoke relay shutdown 2026-03-10 20:22:07 +07:00
Mohamed Boudra
e1f4e6fafb Fix Windows smoke relay launch 2026-03-10 19:43:30 +07:00
Mohamed Boudra
f09b43eef0 Tighten Windows desktop smoke loop 2026-03-10 19:22:54 +07:00
Mohamed Boudra
2813f35eb1 Relax Windows smoke app build failures 2026-03-10 18:49:10 +07:00
Mohamed Boudra
90dfe36e3e Speed up Windows desktop smoke 2026-03-10 18:27:02 +07:00
Mohamed Boudra
7588c1791b Remove accidental release notes 2026-03-10 18:23:43 +07:00
Mohamed Boudra
bfa7f65c3d chore(release): cut 0.1.24 2026-03-10 18:12:16 +07:00
Mohamed Boudra
51bbebcdd5 Fix Windows smoke npm invocation 2026-03-10 18:12:09 +07:00
Mohamed Boudra
7b4ca8394b chore(release): cut 0.1.23 2026-03-10 18:01:55 +07:00
Mohamed Boudra
438a9f6d48 Fix Windows smoke path resolution 2026-03-10 18:01:39 +07:00
Mohamed Boudra
9604b8d57b chore(release): cut 0.1.22 2026-03-10 17:44:28 +07:00
Mohamed Boudra
2a0b0b9109 Fix Windows runtime packaging 2026-03-10 17:44:16 +07:00
Mohamed Boudra
f3338ee824 chore(release): cut 0.1.21 2026-03-10 17:25:20 +07:00
Mohamed Boudra
e73d40b260 Fix release follow-up issues 2026-03-10 17:25:03 +07:00
Mohamed Boudra
89a25276a5 chore(release): cut 0.1.20 2026-03-10 17:09:13 +07:00
Mohamed Boudra
244eed8696 Finalize release content 2026-03-10 17:08:25 +07:00
Mohamed Boudra
dce7316931 Skip duplicate smoke artifact rebuilds 2026-03-10 16:45:34 +07:00
Mohamed Boudra
d69addaad2 Relax relay startup timeout in desktop smoke 2026-03-10 16:03:33 +07:00
Mohamed Boudra
845cf68d38 Refactor git actions and update React to 19.1.4 2026-03-10 15:49:33 +07:00
Mohamed Boudra
2b17aa1a1d Skip GitHub release publishing for smoke tags 2026-03-10 15:23:53 +07:00
Mohamed Boudra
d32c196fd5 Cache desktop release dependencies 2026-03-10 15:06:09 +07:00
Mohamed Boudra
a0266e29e3 Avoid notarization in macOS smoke prebuild 2026-03-10 14:54:26 +07:00
Mohamed Boudra
752d29c146 Prebuild macOS smoke app in CI 2026-03-10 14:38:24 +07:00
Mohamed Boudra
36660b3cc1 Import Apple cert before runtime signing 2026-03-10 13:59:36 +07:00
Mohamed Boudra
bf355aaaf3 Sign macOS managed runtime artifacts 2026-03-10 13:39:58 +07:00
Mohamed Boudra
98d91fd696 Instrument desktop smoke hangs 2026-03-10 13:07:10 +07:00
Mohamed Boudra
8dfc866d40 Enhance CLI section with bash syntax highlighting and updated examples 2026-03-10 13:04:58 +07:00
Mohamed Boudra
15e9569157 refactor: extract stream render model and segment-based rendering 2026-03-10 12:56:18 +07:00
Mohamed Boudra
483dd7cb6d Use supported Intel macOS runner 2026-03-10 12:40:03 +07:00
Mohamed Boudra
6a0e48c10c Fix desktop smoke managedHome references 2026-03-10 12:35:13 +07:00
Mohamed Boudra
7a4be5233c Harden desktop smoke bootstrap check in CI 2026-03-10 12:19:16 +07:00
Mohamed Boudra
965704da20 refactor: extract settings styles and improve badge/button UI 2026-03-10 12:17:37 +07:00
Mohamed Boudra
f760255d50 Avoid macOS CI CLI shim prompt in smoke test 2026-03-10 11:49:35 +07:00
Mohamed Boudra
f3acdedfb1 Fix desktop release runtime bundling 2026-03-10 11:41:49 +07:00
Mohamed Boudra
cc45c3772f feat: add multi-platform downloads and improve homepage animations 2026-03-10 11:20:17 +07:00
Mohamed Boudra
a3e271a1e7 fix: use freshest comparison base for git status and shortstat 2026-03-09 17:42:27 +07:00
Mohamed Boudra
7b22fc5c3f refactor: extract Claude binary lookup into separate function 2026-03-09 16:49:00 +07:00
Mohamed Boudra
a15b52efc8 feat: add diff stats and archive actions to workspace sidebar 2026-03-09 16:38:36 +07:00
Mohamed Boudra
7f11b93e0f fix: add missing Rust imports for Windows build and check in pending changes
Add `use std:#️⃣:{DefaultHasher, Hash, Hasher}` behind #[cfg(windows)]
in runtime_manager.rs — these types are used in hash_seed() which only
compiles on Windows, causing CI failure.

Also includes: app component refactors (agent-list, agent-status-bar,
stream-strategy-web), website index updates, server dep additions
(fast-uri, rotating-file-stream sort), lockfile sync, and removal of
RUNTIME_SIMPLIFICATION_PLAN.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:09:48 +07:00
Mohamed Boudra
02d74777b2 fix(ci): regenerate lockfile for cross-platform optional deps
npm/cli#4828 caused package-lock.json to prune platform variants
not matching the local OS. Regenerated from scratch so Windows CI
gets @tauri-apps/cli-win32-x64-msvc and lightningcss-win32-x64-msvc.

Removed the lightningcss Windows install workaround from desktop
workflow. Removed deprecated asyncRequireModulePath from metro config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:42:35 +07:00
Mohamed Boudra
cf148ba3af fix(ci): stabilize platform-scoped desktop job gating 2026-03-09 14:47:39 +07:00
Mohamed Boudra
19b6aaa2f3 fix(ci): add platform-scoped desktop retry tags 2026-03-09 13:40:50 +07:00
Mohamed Boudra
97737be91c fix(ci): install lightningcss for windows desktop builds 2026-03-09 13:15:45 +07:00
Mohamed Boudra
e3d7dabb87 fix(ci): support desktop release retries 2026-03-09 12:58:41 +07:00
Mohamed Boudra
a90a7f454c chore(release): cut 0.1.19 2026-03-09 11:41:44 +07:00
Mohamed Boudra
8a60dc30d6 feat(release): add draft GitHub release flow 2026-03-09 11:41:21 +07:00
Mohamed Boudra
06f8722f25 Split stream rendering into platform-specific strategies 2026-03-09 11:13:57 +07:00
Mohamed Boudra
e3552f6365 Merge branch 'managed-daemon-bundling' 2026-03-08 20:48:39 +07:00
Mohamed Boudra
7c6eb2ad74 Add detailed logging to bottom anchor controller and scroll strategy 2026-03-08 20:48:37 +07:00
Mohamed Boudra
2721ce331a Simplify managed runtime to execute in place from app bundle 2026-03-08 20:48:13 +07:00
Mohamed Boudra
ca787271b3 Support per-arch desktop builds and prune runtime artifacts 2026-03-08 17:06:05 +07:00
Mohamed Boudra
87948e956a refactor keyboard shortcut tests into table-driven suites 2026-03-08 16:36:05 +07:00
Mohamed Boudra
1e5e0f625d Clarify desktop daemon wording and helper text 2026-03-08 16:05:28 +07:00
Mohamed Boudra
6f7b3db4fa docs: remove managed CLI plan 2026-03-08 15:46:07 +07:00
Mohamed Boudra
93b5cc530c test: align host runtime connection type 2026-03-08 15:41:04 +07:00
Mohamed Boudra
785124eb9f docs: tighten managed CLI install notes 2026-03-08 15:40:02 +07:00
Mohamed Boudra
2a1ef17107 feat: implement two-shim CLI install with fallback instructions 2026-03-08 15:40:02 +07:00
Mohamed Boudra
2d5d0dcacd Add managed desktop daemon runtime support 2026-03-08 15:40:02 +07:00
Mohamed Boudra
faa5aaab6f Refine agent list attention handling 2026-03-08 15:39:40 +07:00
Mohamed Boudra
03e1915316 Improve agent input placeholder 2026-03-08 14:17:16 +07:00
Mohamed Boudra
b339c5e61d Fix bottom anchoring and Claude wake routing 2026-03-08 12:54:08 +07:00
Mohamed Boudra
0c44e7db80 refactor: prefix distributable skills with paseo- namespace
Rename handoff, committee, loop to paseo-handoff, paseo-committee,
paseo-loop to avoid name collisions with other skill sources.
Symlinked ~/.agents/skills/ and ~/.claude/skills/ to the repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:36:10 +07:00
Josep Lluis Giralt D'Lacoste ( Pep )
870d95f35e fix(test): add missing getRuntimeMetrics mock to MockSession (#92)
The production code calls connection.session.getRuntimeMetrics() when
closing the WebSocket server, but MockSession in the relay-reconnect
tests didn't implement this method, causing all close() calls to throw.
2026-03-07 22:35:18 +08:00
Zi Makki
cfb9784ea9 Fix Android autolinking cache for variant builds (#93) 2026-03-07 22:35:08 +08:00
Mohamed Boudra
484edb1e1e feat: add distributable skills (paseo, handoff, committee, loop)
Package the four core skills into the repo under skills/ so users can
install them via `npx skills add getpaseo/paseo`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:34:39 +07:00
Mohamed Boudra
356faa0563 feat(app): add project picker modal and simplify open project screen 2026-03-07 21:33:13 +07:00
Mohamed Boudra
7d76da3249 Update workspace and app changes 2026-03-07 19:09:00 +07:00
Mohamed Boudra
1d1c7058f1 feat: add agent delete command and bulk tab close operations 2026-03-07 12:32:49 +07:00
Mohamed Boudra
6b51088f39 fix(app): remove initial workspace white flash 2026-03-06 23:55:39 +07:00
Mohamed Boudra
efa6a6aed3 chore(release): cut 0.1.18 2026-03-06 23:48:34 +07:00
Mohamed Boudra
ba054b4dfb docs(changelog): add 0.1.18 notes 2026-03-06 23:48:07 +07:00
Mohamed Boudra
604b41db9b refactor(app): extract project icon placeholder label logic 2026-03-06 23:43:02 +07:00
Zi Makki
8dd94757d7 fix(server): restore auto metadata generation 2026-03-06 17:36:29 +01:00
Mohamed Boudra
a99edcc0b2 feat(app): auto-focus terminal on create/switch, stabilize sidebar ordering for new items 2026-03-06 23:19:12 +07:00
Mohamed Boudra
1985bd6669 feat(server): add Phoenix priv/static to project icon search directories 2026-03-06 23:09:40 +07:00
Mohamed Boudra
674573938a Merge remote-tracking branch 'origin/tool-version-changes' 2026-03-06 22:53:46 +07:00
Mohamed Boudra
7821a8a8af feat(app): add Mod+W close-tab shortcut for desktop, simplify Android build scripts, and use universal DMG download 2026-03-06 22:53:03 +07:00
Zi Makki
226ece2bdd add expo stuff 2026-03-06 16:49:40 +01:00
Zi Makki
cec0b96adb updates to tool versions 2026-03-06 16:49:40 +01:00
Zi Makki
9d64c3e01e adding mise toml file 2026-03-06 16:49:40 +01:00
Mohamed Boudra
bb300fa2f8 fix(app): unblock deploy app timer typings [skip ci] 2026-03-06 22:33:20 +07:00
560 changed files with 101589 additions and 27274 deletions

View File

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

View File

@@ -3,27 +3,50 @@ name: Desktop Release
on:
push:
tags:
- 'v*'
- 'desktop-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."
required: false
default: "all"
type: choice
options:
- all
- macos
- linux
- windows
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-tauri:
publish-macos:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
rust_target: aarch64-apple-darwin
- runner: macos-15-intel
rust_target: x86_64-apple-darwin
permissions:
contents: write
packages: read
runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
@@ -31,18 +54,80 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- 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'
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
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: aarch64-apple-darwin,x86_64-apple-darwin
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
- name: Install JS dependencies
run: npm ci
@@ -52,46 +137,40 @@ jobs:
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
- 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:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
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 field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.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 package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Tauri release
- 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 }}
@@ -108,6 +187,407 @@ jobs:
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --target universal-apple-darwin
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 }}
shell: bash
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
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
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'))) }}
permissions:
contents: write
packages: read
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- 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"
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
- 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
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
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
- name: Build Linux Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: linux_tauri
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "1"
APPIMAGE_EXTRACT_AND_RUN: "1"
shell: bash
run: |
set -euo pipefail
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
- name: Attempt manual Linux AppImage fallback
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euxo pipefail
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
appdir_path="$appimage_dir/Paseo.AppDir"
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -n "$existing_appimage" ]; then
if [ "$existing_appimage" != "$canonical_appimage" ]; then
mv "$existing_appimage" "$canonical_appimage"
fi
if [ ! -f "$canonical_appimage.sig" ]; then
npx tauri signer sign "$canonical_appimage"
fi
exit 0
fi
if [ ! -d "$appdir_path" ]; then
echo "::error::AppDir was not generated at $appdir_path"
exit 1
fi
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
npx tauri signer sign "$canonical_appimage"
- name: Fail Linux release when AppImage bundling fails
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::Linux AppImage assets are still missing after the manual fallback."
exit 1
fi
- name: Upload Linux release assets
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
fi
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
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'))) }}
permissions:
contents: write
packages: read
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- 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"
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
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
- name: Build and publish Windows Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: tauri-apps/tauri-action@v0
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
- 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 }}

View File

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

View File

@@ -36,8 +36,14 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/server
- 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 }}

4
.gitignore vendored
View File

@@ -71,8 +71,12 @@ 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
/artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/

9
.mise.toml Normal file
View File

@@ -0,0 +1,9 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
]
[tools]
java = "17"

View File

@@ -1,2 +1,4 @@
rust 1.85.1
nodejs 22.20.0
rust 1.85.1
nodejs 22.20.0
java 21
android-sdk latest

View File

@@ -1,5 +1,121 @@
# 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
- Improved command center keyboard navigation and new tab shortcut.
- Simplified desktop release pipeline for faster and more reliable builds.
## 0.1.21 - 2026-03-10
### Improved
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
### Fixed
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
## 0.1.20 - 2026-03-10
### Added
- Added workspace sidebar git actions with quick diff stats and archive controls.
- Added refreshed website downloads and homepage presentation for desktop installs.
### Improved
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
### Fixed
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
## 0.1.19 - 2026-03-09
### Added
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
## 0.1.18 - 2026-03-06
### Added
- Added a desktop `Mod+W` shortcut to close the current tab.
### Improved
- New and newly selected terminals now take focus automatically so you can type immediately.
- Kept newly created workspaces and projects in a more stable order in the sidebar.
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
- Updated the website desktop download link to use the universal macOS DMG.
### Fixed
- Restored automatic agent metadata generation for Claude runs.
## 0.1.17 - 2026-03-06
### Added
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.

262
CLAUDE.md
View File

@@ -1,248 +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` — Tauri 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 --clean --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# production (release)
APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
```
From repo root:
```bash
npm run android:development
npm run android:production
```
`npm run android:prod` and `npm run android:release` are aliases 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)
```
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

@@ -1,160 +0,0 @@
# Execution Plan — Iteration 2 Projects → Workspaces → Tabs (Paseo Orchestrated)
This document describes **how** we will execute the fixes defined in:
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
It is optimized for parallel work using **Paseo-managed worktrees** and strict quality gates.
---
## Constraints / Guardrails
- **Do not restart or modify** the users main daemon on `localhost:6767`.
- Use **isolated dev stacks** for manual verification (new daemon + new Metro) via:
- `PASEO_HOME=~/.paseo-<unique>` and `npm run dev` (auto-picks free ports).
- No “legacy view” preserved: we fix the current UX directly (no dead/unused code paths left behind).
- Agents must treat **terminals and agents as equal first-class tab types** (no special layouts).
- Keep changes focused to the reported issues; avoid unrelated refactors.
---
## Work Breakdown (Parallel)
### Agent A — Sidebar drag scoping + sidebar polish
**Worktree:** `polish/sidebar-dnd-and-style`
Responsibilities:
- Fix project drag so dragging a **project header** reorders the **entire project section** (header + workspaces).
- Fix workspace drag so workspaces reorder **only within their project** (no cross-project placement).
- Ensure sidebar list **snaps back** to canonical `Project → Workspaces` structure after any drag.
- Sidebar visuals:
- remove workspace “border” style, match project “ghost” style language
- remove “No agents yet”
- reduce workspace indentation/padding (mobile-friendly)
- Navigation polish:
- clicking a workspace closes the left sidebar (mobile)
### Agent B — Workspace header + tabs polish (icons, unified create, persistence)
**Worktree:** `polish/workspace-tabs-and-header`
Responsibilities:
- Workspace header shows **branch name** for git workspaces (including base branch like `main`).
- Replace separate “create agent” vs “create terminal” rows with **one unified New Tab control** (agent + terminal).
- Agent tabs show **provider icons** (Claude + Codex minimum, using existing assets/components).
- Fix “remember focused tab per workspace” so:
- switching away and back restores the last focused agent/terminal tab
- stored selection is **not overwritten** while agent/terminal lists are still loading
### Agent C — Review / sanity check (no code changes)
Runs after merges to:
- review diff for edge cases + regressions
- double-check acceptance criteria mapping
- call out missing verification steps
---
## Agent Launch Commands (local CLI)
We use the repo-local CLI:
```bash
npm run -s cli -- run --provider codex --model gpt-5.3-codex --mode full-access --worktree <name> --name "<title>" --detach "<prompt>" --quiet
```
Notes:
- `--detach --quiet` returns the agent ID quickly so we can launch in parallel.
- Each agent must **commit** their work in their worktree branch before finishing.
---
## Prompts (exact)
### Prompt for Agent A
Title: `🎭 Sidebar DnD + Polish`
Prompt:
- Implement **only** the items in “Sidebar drag behavior” + “Sidebar visuals + navigation polish” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
- Do not change gestures beyond the required drag constraints.
- Ensure the post-drag list snaps back to the canonical project/workspace grouping.
- Remove “No agents yet” and fix workspace row styling/indentation.
- Close sidebar on workspace selection (mobile).
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
- Commit with a clear message.
### Prompt for Agent B
Title: `🎭 Workspace Tabs + Header`
Prompt:
- Implement **only** the items in “Workspace header + tab bar fixes” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
- Terminals and agents must be treated as identical first-class tab types (no separate rows/layout).
- Add provider icons for agent tabs (Claude/Codex minimum) using existing app icon components.
- Fix per-workspace focused-tab persistence (dont overwrite selection while queries are pending).
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
- Commit with a clear message.
### Prompt for Agent C (review-only)
Title: `🎭 Review: Sidebar + Tabs Polish`
Prompt:
- Review the combined diff for correctness vs acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
- DO NOT edit code. Provide a checklist of anything missing or risky.
---
## Merge Strategy (back to `main`)
1. Wait for Agents A + B to complete.
2. For each worktree branch:
- verify it has a clean commit history (no unrelated changes)
- re-run `npm run typecheck` if needed
3. Merge into `main` sequentially:
- merge A
- rebase/merge B on top of updated `main` (resolve conflicts if any)
4. Do not delete/prune worktrees until the user has manually verified.
---
## Verification Gates (strict)
### 1) Automated (must pass)
From repo root on `main` after merges:
```bash
npm run typecheck
npm run test --workspace=@getpaseo/app
```
Optional (run if environment supports it; starts isolated daemon/metro itself):
```bash
npm run test:e2e --workspace=@getpaseo/app
```
### 2) Manual (must be performed by us before handing back)
Use an **isolated dev stack** (new daemon + new Metro):
```bash
PASEO_HOME=~/.paseo-iter2-polish npm run dev
```
Then use `agent-browser` to verify the “Manual (agent-browser)” section in:
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
---
## Completion Definition
We are “done” when:
- All acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` are met.
- Automated verification gates pass.
- Manual verification steps pass.
- Changes are merged into `main` with no leftover legacy code paths.

View File

@@ -1,60 +0,0 @@
# Iteration 3 Execution Plan — Paseo Orchestrated
## Principles
- Work happens in an isolated git worktree and merges back to `main` once verified.
- Do not restart or touch the daemon on `localhost:6767`.
- Use the repos existing Playwright global setup (isolated daemon/metro) for E2E.
- Pass all gates before merging:
- Typecheck
- Vitest
- Playwright E2E
- Manual `agent-browser` verification
## Agent Delegation (Paseo)
### Implementation agent (1)
- **Agent:** Codex
- **Mode:** full-access
- **Worktree:** `iter3-workspace-header-tabs-restore`
- **Mission:**
- Fix New tab dropdown visibility (on-screen, correct pattern)
- Restore workspace header structure + explorer toggle + agent kebab menu
- Add terminal close (X) from workspace tab strip
- Add/adjust Playwright E2E specs for the above
- Run verification commands before declaring done
### Reviewer (optional, if needed)
- Only used if implementation is large/risky or tests expose subtle regressions.
- Codex or Claude Sonnet as a second-pass reviewer for UI regressions.
## Merge Strategy
1. Agent commits all changes in the worktree.
2. Orchestrator reviews the diff on the worktree.
3. Run gates locally on the worktree:
- `npm run typecheck`
- `npm run test --workspace=@getpaseo/app`
- `npm run test:e2e --workspace=@getpaseo/app`
4. Manual `agent-browser` verification:
- Desktop viewport: New tab menu visible + explorer toggle + kebab menu
- Mobile viewport: explorer icon uses git/folder, toggles right sidebar, left sidebar unaffected
5. Merge worktree back into `main` with a fast-forward merge if possible; otherwise merge commit.
## Verification Checklist (strict)
- [ ] New tab menu opens and is visible (desktop)
- [ ] Selecting Agent tab routes to draft agent flow scoped to workspace
- [ ] Selecting Terminal tab creates terminal and focuses it
- [ ] Terminal tabs show X; closing kills terminal and removes tab
- [ ] Workspace header shows branch name
- [ ] Workspace header has explorer toggle with correct icon behavior
- [ ] Agent kebab menu exists when Agent tab active
- [ ] Right sidebar opens/closes via header and mobile swipe gesture
- [ ] Left sidebar gestures unchanged
- [ ] `npm run typecheck`
- [ ] `npm run test --workspace=@getpaseo/app`
- [ ] `npm run test:e2e --workspace=@getpaseo/app`

View File

@@ -1,113 +0,0 @@
# Execution Plan — Iteration 4 (Paseo-orchestrated)
## Strategy
Use a Paseo-managed implementation agent in an isolated git worktree to patch the workspace screen to restore legacy header/layout parity while keeping tabs. Then review, validate with tests + agent-browser, and merge back to `main`.
## Agents
### 1) Implementation agent (Codex)
- Provider/model: `codex / gpt-5.3-codex`
- Mode: `full-access`
- Worktree: `iter4-workspace-header-layout-restore` (base: `main`)
- Responsibilities:
- Fix `New tab` dropdown to use the established dropdown/menu pattern (not off-screen combobox).
- Restore workspace header explorer toggle parity with legacy `AgentReadyScreen` (icons, aria state).
- Restore agent overflow (kebab) menu when active tab is an agent.
- Add terminal tab close `X` on desktop with confirm + kill terminal mutation.
- Add/extend Playwright E2E specs for `New tab` on-screen + explorer toggle open/close.
- Commit changes.
### 2) Reviewer/validator (you/me)
- Review diffs locally.
- Run:
- `npm run typecheck`
- `npm run test --workspace=@getpaseo/app`
- Targeted Playwright spec(s) for this iteration
- Perform agent-browser manual verification (desktop + mobile viewports).
## Tooling / Commands (canonical)
### Create agent (detached)
```bash
paseo run -d \
--worktree iter4-workspace-header-layout-restore \
--base main \
--provider codex \
--model gpt-5.3-codex \
--mode full-access \
--name "🎭 Iter4 workspace header/layout restore" \
"<paste the implementation prompt>"
```
### Wait
```bash
paseo wait <agent-id>
```
### Review worktree diff
```bash
cd ~/.paseo/worktrees/<hash>/iter4-workspace-header-layout-restore
git status --short --branch
git log -n 5 --oneline
git diff main..HEAD
```
### Merge into main
Prefer `git cherry-pick <commit>` into `/Users/moboudra/dev/paseo` `main` after verification.
## Verification gates (must be green)
### 1) Typecheck
```bash
npm run typecheck
```
### 2) App unit tests
```bash
npm run test --workspace=@getpaseo/app
```
### 3) Playwright E2E (targeted)
Run only the spec(s) for this iteration (avoid unrelated flakes):
```bash
cd packages/app
npx playwright test e2e/workspace-header-tabs-restore.spec.ts
```
## Manual verification (agent-browser)
Use explicit sessions:
```bash
agent-browser --session iter4-desktop open http://localhost:8081
agent-browser --session iter4-mobile open http://localhost:8081
```
Desktop:
- Validate `New tab` dropdown opens on-screen
- Validate explorer toggle opens/closes explorer
- Validate kebab menu appears for agent tabs
- Validate terminal tab `X` close flow
Mobile viewport:
- Validate git/folder icon for explorer toggle
- Validate gestures for left/right sidebars
## Rollback plan
If verification fails:
- Do not merge.
- Patch in worktree until acceptance criteria + gates pass.
- Only then cherry-pick/merge into `main`.

View File

@@ -1,138 +0,0 @@
# Execution Plan (Paseo Orchestrator)
This document is the concrete execution plan for `PLAN_PROJECTS_WORKSPACES_TABS.md`, using the Paseo CLI to delegate work to sub-agents running inside isolated git worktrees.
## Conventions
- All agent names are prefixed with `🎭` so theyre easy to identify.
- Use **Codex** for implementation work (`--provider codex --mode full-access`).
- Each agent runs in its **own worktree** to avoid concurrent writes to the same git working directory.
- Agents must avoid “legacy/compat mode” code paths: we are fully redirecting/reworking.
## 0) Preflight
```bash
git branch --show-current
npm run -s cli -- daemon status
```
Expected:
- current branch is `main` (or the branch you want as base)
- daemon is reachable
## 1) Launch parallel agents (detached)
### Agent A — Server protocol + workspace data
Scope:
- Replace file explorer + download token RPC to be **workspace-scoped** (remove `agentId` usage).
- Extend worktree list payload with `createdAt` (and a stable `workspaceId` if needed).
- Ensure agent index loads once at daemon startup (no repeated disk hydration).
```bash
SERVER_ID=$(npm run -s cli -- run -d -q \
--name "🎭 PWT Server: workspace RPCs" \
--provider codex --mode full-access \
--worktree pwt-server-workspace-rpcs --base main \
"Implement server-side changes from PLAN_PROJECTS_WORKSPACES_TABS.md: replace file_explorer_request and file_download_token_request to be workspace-scoped (no agentId), update handlers + client types, add createdAt to paseo_worktree_list_response, and make agent storage/index load once at daemon startup. Do NOT add legacy compatibility. Keep changes minimal and typecheck. Output a short checklist of touched files + how to test."
)
echo "$SERVER_ID"
```
### Agent B — App: workspace routes + tabs main view
Scope:
- Add workspace routes/screens and redirect old agent routes.
- Implement workspace header + tab bar (agent + terminal tabs).
- Persist/restore last focused tab per workspace.
- Mobile tab switcher + plus button (draft agent flow pre-scoped).
```bash
APP_TABS_ID=$(npm run -s cli -- run -d -q \
--name "🎭 PWT App: workspace tabs" \
--provider codex --mode full-access \
--worktree pwt-app-workspace-tabs --base main \
"Implement app-side workspace main view + tabs from PLAN_PROJECTS_WORKSPACES_TABS.md. Replace old /h/:serverId/agent routes with workspace routes, render a workspace screen with a horizontal tab bar (agent + terminal tabs), restore last focused tab, and implement a mobile tab switcher + header plus button to open draft agent flow pre-scoped to workspace. Terminals are just tabs; no special casing. Do NOT keep legacy UI. Keep gestures/overlay sidebars unchanged. Typecheck."
)
echo \"$APP_TABS_ID\"
```
### Agent C — App: left sidebar projects → workspaces
Scope:
- Replace left sidebar agent list with Project → Workspace tree.
- Project icon, status dot aggregation, reorder persistence.
- Workspace rows: branch + createdAt, no path.
```bash
APP_SIDEBAR_ID=$(npm run -s cli -- run -d -q \
--name "🎭 PWT App: projects sidebar" \
--provider codex --mode full-access \
--worktree pwt-app-projects-sidebar --base main \
"Implement the left sidebar rewrite per PLAN_PROJECTS_WORKSPACES_TABS.md: Projects grouped by projectKey (remote when available else local), each project shows icon + status dot, and contains workspaces (main checkout + Paseo worktrees incl empty). Workspace row shows branch name + createdAt only (no path). Keep drag reorder for projects + workspaces and persist on-device. Do not show agents in sidebar. Typecheck."
)
echo \"$APP_SIDEBAR_ID\"
```
### Agent D — App: right sidebar changes + files (workspace-scoped)
Scope:
- Remove terminals from the right sidebar.
- Make Changes/Files sidebar workspace-scoped (not agent-scoped).
- Update file explorer calls to use new workspace-scoped RPC.
```bash
APP_EXPLORER_ID=$(npm run -s cli -- run -d -q \
--name "🎭 PWT App: explorer sidebar" \
--provider codex --mode full-access \
--worktree pwt-app-explorer-sidebar --base main \
"Update the right sidebar per PLAN_PROJECTS_WORKSPACES_TABS.md: it must contain only Changes + Files and be scoped to the opened workspace (not the selected tab). Remove terminals from the right sidebar entirely. Replace file explorer client actions/state to use the new workspace-scoped RPC (no agentId). Ensure empty workspaces still work. Typecheck."
)
echo \"$APP_EXPLORER_ID\"
```
## 2) Wait for completion
```bash
npm run -s cli -- wait "$SERVER_ID"
npm run -s cli -- wait "$APP_TABS_ID"
npm run -s cli -- wait "$APP_SIDEBAR_ID"
npm run -s cli -- wait "$APP_EXPLORER_ID"
```
## 3) Collect diffs from each worktree
```bash
SERVER_CWD=$(npm run -s cli -- inspect "$SERVER_ID" --json | jq -r '.cwd')
APP_TABS_CWD=$(npm run -s cli -- inspect "$APP_TABS_ID" --json | jq -r '.cwd')
APP_SIDEBAR_CWD=$(npm run -s cli -- inspect "$APP_SIDEBAR_ID" --json | jq -r '.cwd')
APP_EXPLORER_CWD=$(npm run -s cli -- inspect "$APP_EXPLORER_ID" --json | jq -r '.cwd')
git -C "$SERVER_CWD" diff > /tmp/pwt-server.patch
git -C "$APP_TABS_CWD" diff > /tmp/pwt-app-tabs.patch
git -C "$APP_SIDEBAR_CWD" diff > /tmp/pwt-app-sidebar.patch
git -C "$APP_EXPLORER_CWD" diff > /tmp/pwt-app-explorer.patch
```
## 4) Integrate (apply patches in order)
Recommended: create a clean integration worktree/branch first, then apply patches.
```bash
# In a clean integration branch/worktree:
git apply /tmp/pwt-server.patch
git apply /tmp/pwt-app-tabs.patch
git apply /tmp/pwt-app-sidebar.patch
git apply /tmp/pwt-app-explorer.patch
npm run typecheck
```
If `git apply` fails due to overlap, apply one patch at a time and resolve manually, then re-run typecheck.
## 5) QA pass
- Verify acceptance criteria list in `PLAN_PROJECTS_WORKSPACES_TABS.md`.
- Smoke test navigation: open workspace → tabs → right sidebar → plus flow.
- Confirm no remaining agentId-based explorer/download usage.

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)
@@ -57,4 +65,4 @@ npm run typecheck
## License
MIT
AGPL-3.0

1
cli-client-id Normal file
View File

@@ -0,0 +1 @@
cid_518a41c4c44340aea1120d2b760fc6c6

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) │ │ (Tauri) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 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 (Tauri)
Tauri 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**: Tauri 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
```

341
docs/PANEL-REFACTOR-PLAN.md Normal file
View File

@@ -0,0 +1,341 @@
# Panel Interface Refactor Plan
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
## The Problem
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
## Target Architecture
### 1. PanelRegistration Interface
```typescript
// panels/panel-registry.ts
interface PanelDescriptor {
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
}
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
component: React.ComponentType;
useDescriptor(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): Promise<boolean>;
}
```
### 2. Panel Registry
```typescript
const panelRegistry = new Map<string, PanelRegistration>();
function registerPanel(registration: PanelRegistration): void {
panelRegistry.set(registration.kind, registration);
}
function getPanelRegistration(kind: string): PanelRegistration | undefined {
return panelRegistry.get(kind);
}
```
### 3. PaneContext
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
```typescript
interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
```
### 4. WorkspaceTabTarget stays unchanged
```typescript
type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
```
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
## Panel Implementations
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
### Agent Panel Example
```typescript
// panels/agent-panel.ts
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const agent = useSessionStore(
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
);
const provider = agent?.provider ?? "codex";
const label = resolveAgentLabel(agent?.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: agentIconForProvider(provider),
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
};
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
async confirmClose(target, context) {
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
if (agent?.status === "running") {
return confirmDialog({ title: "Agent is still running. Close anyway?" });
}
return true;
},
};
```
### Terminal Panel Example
```typescript
function useTerminalPanelDescriptor(
target: { kind: "terminal"; terminalId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
// read terminal data from appropriate store
return {
label: "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: TerminalIcon,
statusBucket: null,
};
}
function TerminalPanel() {
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (terminalId) {
openTab({ kind: "terminal", terminalId });
}
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
```
### Draft Panel Example
```typescript
function useDraftPanelDescriptor(
_target: { kind: "draft"; draftId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
return {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready",
icon: PencilIcon,
statusBucket: null,
};
}
function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={(agentSnapshot) => {
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
}}
/>
);
}
```
### File Panel Example
```typescript
function useFilePanelDescriptor(
target: { kind: "file"; path: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
return {
label: fileName,
subtitle: target.path,
titleState: "ready",
icon: FileTextIcon,
statusBucket: null,
};
}
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "file", "FilePanel requires file target");
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceId}
filePath={target.path}
/>
);
}
```
## How the Tab Bar Uses It
Each tab chip calls the panel's `useDescriptor` hook:
```typescript
function TabChip({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
invariant(registration, `No panel registration for kind: ${target.kind}`);
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
return (
<TabChipChrome
tabId={tabId}
label={descriptor.label}
subtitle={descriptor.subtitle}
titleState={descriptor.titleState}
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
statusBucket={descriptor.statusBucket}
/>
);
}
```
## How the Workspace Screen Renders Content
Replaces the entire `renderContent()` switch:
```typescript
function PaneContent({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
if (!registration) return null;
const Component = registration.component;
return (
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
<Component />
</PaneProvider>
);
}
```
## Implementation Steps
### Step 1: Create panel registry infrastructure
Create the following new files:
- `packages/app/src/panels/panel-registry.ts``PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
- `packages/app/src/panels/pane-context.ts``PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
### Step 2: Create panel registration files
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
### Step 3: Refactor workspace-tab-model.ts
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
### Step 4: Refactor workspace-screen.tsx renderContent()
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
### Step 5: Refactor workspace-tab-presentation.tsx
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
### Step 6: Verify
- `npm run typecheck` must pass
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
- Mobile tab switcher must work unchanged
- No visual regressions in tab bar, icons, status indicators
## Constraints
- **Pure refactor** — zero user-visible behavior changes
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
- **WorkspaceTabTarget stays unchanged** — no store migration
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
- **Do not create index.ts barrel files** — project convention
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
- **Use `function` declarations** — project convention (no arrow function components)
- **Use `interface` over `type` where possible** — project convention
- **Run `npm run typecheck` after every change** — project rule

65
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,65 @@
# 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
## 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
- 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

275
docs/SPLIT-PANES-PLAN.md Normal file
View File

@@ -0,0 +1,275 @@
# Split Panes Plan
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
## Data Model
### Core Types
```typescript
interface SplitPane {
id: string;
tabIds: string[];
focusedTabId: string | null;
}
interface SplitGroup {
id: string;
direction: "horizontal" | "vertical";
children: SplitNode[];
sizes: number[]; // proportional, sum to 1, same length as children
}
type SplitNode =
| { kind: "pane"; pane: SplitPane }
| { kind: "group"; group: SplitGroup };
interface WorkspaceLayout {
root: SplitNode;
focusedPaneId: string;
}
```
### Design Decisions
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
- **Max depth: 4 levels.**
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
### Default State
Every workspace starts with:
```typescript
{
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
focusedPaneId: "main",
}
```
### Migration
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
## Store Actions
### Everyday Operations (pane-agnostic)
These don't take a `paneId`. Mobile code only uses these.
```typescript
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
closeTab(workspaceKey: string, tabId: string): void;
focusTab(workspaceKey: string, tabId: string): void;
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
```
- `openTab` creates the tab and adds it to the focused pane.
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
### Split Operations (desktop only)
```typescript
splitPane(workspaceKey: string, input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}): string | null; // new pane ID, or null if depth cap hit
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
focusPane(workspaceKey: string, paneId: string): void;
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
```
## Tree Transformations
### splitPane
**Position mapping:**
- `left` / `right``horizontal` direction
- `top` / `bottom``vertical` direction
- `left` / `top` → new pane inserted before target
- `right` / `bottom` → new pane inserted after target
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
```
Before: horizontal([A, B])
Split B right with tab X
Optimized: horizontal([A, B, C]) ← insert into existing group
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
```
**Steps:**
1. Check depth — reject if would exceed 4 levels
2. Remove `tabId` from source pane (could be same or different pane)
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
6. If source pane is now empty → collapse it
7. Set `focusedPaneId` to new pane
### collapsePane
Triggered when a pane's last tab is removed or moved out.
```
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
B loses last tab
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
```
**Steps:**
1. Remove pane from parent group's children
2. Remove corresponding entry from parent's sizes
3. Renormalize sizes to sum to 1
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
5. Unwrap can cascade up the tree
6. Move focus to nearest sibling
### moveTabToPane
Tab dragged from one pane to another existing pane.
1. Remove `tabId` from source pane's `tabIds`
2. Insert into target pane's `tabIds` at drop position (or end)
3. Set target pane's `focusedTabId` to the moved tab
4. If source pane is now empty → collapsePane
5. Set `focusedPaneId` to target pane
### resizeSplit
User drags a divider between panes.
1. Find group by ID
2. Update the two adjacent sizes based on drag delta
3. Clamp each child to minimum proportion (0.1)
4. Renormalize so sizes sum to 1
## Keyboard Shortcuts
| Action | Shortcut |
|---|---|
| Split right | `Cmd+\` |
| Split down | `Cmd+Shift+\` |
| Focus pane left | `Cmd+Shift+←` |
| Focus pane right | `Cmd+Shift+→` |
| Focus pane up | `Cmd+Shift+↑` |
| Focus pane down | `Cmd+Shift+↓` |
| Move tab to pane left | `Cmd+Shift+Alt+←` |
| Move tab to pane right | `Cmd+Shift+Alt+→` |
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
| Close pane | `Cmd+Shift+W` |
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
## Drag and Drop UX
### Drop Zones
When dragging a tab over a pane, the pane is divided into 5 drop zones:
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
- **Left edge** (leftmost 15%) — split left
- **Right edge** (rightmost 15%) — split right
- **Top edge** (topmost 15%) — split up
- **Bottom edge** (bottommost 15%) — split down
### Overlay Preview
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
### Cross-Pane Tab Drag
Tabs can be dragged:
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
- From one pane's tab bar to another pane's tab bar → move tab to that pane
- From a tab bar to a pane's drop zone → split
When dragging the last tab out of a pane, the pane collapses after the drop completes.
## Implementation Steps
### Step 1: Layout Store
Create `packages/app/src/stores/workspace-layout-store.ts`:
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
- Zustand store with AsyncStorage persistence
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
- Version 6 migration from flat tab store
### Step 2: Migrate Workspace Screen to Layout Store
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
### Step 3: Split Tree Transformations
Add to the layout store:
- `splitPane` with the parent-direction optimization and depth check
- `collapsePane` with unwrap cascading
- `moveTabToPane`
- `resizeSplit`
Pure tree transformation functions, tested independently.
### Step 4: Split Container Component
Create `packages/app/src/components/split-container.tsx`:
- Recursive component that renders `SplitNode`
- Groups render as flex containers with direction from `SplitGroup.direction`
- Panes render tab bar + active panel content (using the panel registry)
- Resize handles between children of a group
### Step 5: Drop Zones and Overlay
Create `packages/app/src/components/split-drop-zone.tsx`:
- Overlay that appears during tab drag
- Divides pane into 5 zones (center + 4 edges)
- Shows preview rectangle on hover
- Calls `splitPane` or `moveTabToPane` on drop
### Step 6: Cross-Pane Drag
Extend the existing dnd-kit setup:
- Tab bar items remain draggable (existing)
- Pane drop zones become droppable targets
- Tab bar of other panes become droppable targets (move to pane)
- DndContext wraps the entire split container (not individual panes)
### Step 7: Keyboard Shortcuts
Register new actions in `keyboard/actions.ts`:
- `workspace.pane.split.right`, `workspace.pane.split.down`
- `workspace.pane.focus.left/right/up/down`
- `workspace.pane.move-tab.left/right/up/down`
- `workspace.pane.close`
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
### Step 8: Pane Focus Navigation
Implement spatial navigation for `focus.left/right/up/down`:
- Walk the tree to find the focused pane's position in the layout
- Find the nearest pane in the requested direction
- Focus it
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
## Constraints
- Mobile stays single-pane — same store, no special casing
- Max 4 levels of nesting
- Minimum pane size: 10% of parent
- `PaneContext` interface unchanged — no `paneId` added
- Panel registry unchanged — panels don't know about splits
- Existing tab shortcuts work on focused pane, unchanged

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.

16855
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.17",
"version": "0.1.30",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/server",
"packages/app",
"packages/relay",
@@ -26,9 +27,10 @@
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:prod --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",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
@@ -44,6 +46,11 @@
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
@@ -54,6 +61,8 @@
"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",
@@ -68,7 +77,9 @@
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1"
"lightningcss": "1.30.1",
"react": "19.1.4",
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"

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,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

@@ -0,0 +1,290 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export type ScrollMetrics = {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
export type SeededAgent = {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
};
export type DaemonClientInstance = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number
): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function buildReplyBlock(label: string, lineCount = 14): string {
return Array.from({ length: lineCount }, (_, index) => {
const line = (index + 1).toString().padStart(2, "0");
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
}).join("\n");
}
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, lineCount),
].join("\n");
}
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
message: string;
expectedReply: string;
} {
return {
message: buildReplyMessage(label),
expectedReply: buildReplyBlock(label),
};
}
async function loadDaemonClientConstructor(): Promise<new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function seedBottomAnchorAgent(input: {
client: DaemonClientInstance;
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",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
);
}
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, 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(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
);
}
}
return {
id: created.id,
title,
expectedTailText,
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
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.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);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
return {
offsetY,
contentHeight,
viewportHeight,
distanceFromBottom,
};
});
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
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,
})
);
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(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
if (expectedTailText) {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(0);
}
}
export async function expectNearBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function expectDetachedFromBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
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);
});

View File

@@ -1,929 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import {
createAgent,
createAgentInRepo,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
function visibleTestId(page: Page, testId: string) {
return page.locator(`[data-testid="${testId}"]:visible`).first();
}
function visibleTestIdPrefix(page: Page, prefix: string) {
return page.locator(`[data-testid^="${prefix}"]:visible`);
}
let terminalMarkerCounter = 0;
function shortTerminalMarker(prefix: string): string {
terminalMarkerCounter += 1;
return `${prefix.slice(0, 1)}${terminalMarkerCounter.toString(36)}`;
}
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const modernMatch = pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (modernMatch) {
return {
serverId: decodeURIComponent(modernMatch[1]),
agentId: decodeURIComponent(modernMatch[2]),
};
}
const legacyMatch = pathname.match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (legacyMatch) {
return {
serverId: decodeURIComponent(legacyMatch[1]),
agentId: decodeURIComponent(legacyMatch[2]),
};
}
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
}
async function openAgentFromSidebar(page: Page, serverId: string, agentId: string): Promise<void> {
await gotoHome(page);
const row = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect
.poll(
() => {
try {
const parsed = parseAgentFromUrl(page.url());
return `${parsed.serverId}:${parsed.agentId}`;
} catch {
return "";
}
},
{ timeout: 30000 }
)
.toBe(`${serverId}:${agentId}`);
}
async function openNewAgentDraft(page: Page): Promise<void> {
await gotoHome(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(
page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({
timeout: 30000,
});
}
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
const filesTab = visibleTestId(page, "explorer-tab-files");
if (await filesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole("button", { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
await toggle.click();
await expect(filesTab).toBeVisible({ timeout: 30000 });
}
async function openTerminalsPanel(page: Page): Promise<void> {
await ensureExplorerTabsVisible(page);
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
timeout: 30000,
});
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
timeout: 30000,
});
}
async function openFilesPanel(page: Page): Promise<void> {
await ensureExplorerTabsVisible(page);
const filesTab = visibleTestId(page, "explorer-tab-files");
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
timeout: 30000,
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
state?: { desktop?: { agentListOpen?: boolean } };
};
const value = parsed?.state?.desktop?.agentListOpen;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(2);
await tabs.last().click();
}
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
const firstTab = visibleTestIdPrefix(page, "terminal-tab-").first();
await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid");
if (!value) {
throw new Error("Expected terminal tab test id");
}
return value;
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function runTerminalCommandAndWaitForBuffer(
page: Page,
command: string,
expectedText: string
): Promise<void> {
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await page.keyboard.press("Enter");
await expectCurrentTerminalBufferToContain(page, expectedText);
}
async function runTerminalCommandWithPreEnterEcho(
page: Page,
command: string,
expectedText: string
): Promise<void> {
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await expect(surface).toContainText(command, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function readCurrentTerminalBuffer(page: Page): Promise<string> {
const bufferText = await page.evaluate(() => {
try {
const terminal = (window as {
__paseoTerminal?: {
buffer?: {
active?: {
length?: number;
getLine?: (
line: number
) =>
| {
translateToString: (trimRight?: boolean) => string;
}
| null;
};
};
};
}).__paseoTerminal;
const buffer = terminal?.buffer?.active;
const lineCount = buffer?.length ?? 0;
if (!buffer || typeof buffer.getLine !== "function" || lineCount <= 0) {
return "";
}
const lines: string[] = [];
for (let index = 0; index < lineCount; index += 1) {
let line:
| {
translateToString: (trimRight?: boolean) => string;
}
| null = null;
try {
line = buffer.getLine(index);
} catch {
return "";
}
if (!line) {
continue;
}
lines.push(line.translateToString(true));
}
return lines.join("\n");
} catch {
return "";
}
});
if (bufferText.length > 0) {
return bufferText;
}
try {
return await visibleTestId(page, "terminal-surface").innerText();
} catch {
return "";
}
}
async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise<void> {
await expect
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 })
.toContain(marker);
}
async function expectCurrentTerminalBufferNotToContain(page: Page, marker: string): Promise<void> {
await expect
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 5000 })
.not.toContain(marker);
}
async function waitForTerminalAttachToSettle(page: Page): Promise<void> {
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
timeout: 30000,
});
}
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
await expect
.poll(
async () =>
await page.evaluate((target) => {
const terminal = (window as any).__paseoTerminal;
if (!terminal?.buffer?.active?.getLine || !terminal?.buffer?.active?.getNullCell) {
return false;
}
const buffer = terminal.buffer.active;
const nullCell = buffer.getNullCell();
const lineCount = buffer.length ?? 0;
const cols = terminal.cols ?? 0;
for (let y = 0; y < lineCount; y += 1) {
const line = buffer.getLine(y);
if (!line) continue;
const lineText = line.translateToString(true);
const index = lineText.indexOf(target);
if (index === -1) continue;
for (let x = index; x < index + target.length && x < cols; x += 1) {
const cell = line.getCell(x, nullCell);
if (!cell) continue;
if (!cell.isFgDefault()) {
return true;
}
}
}
return false;
}, marker),
{ timeout: 30000 }
)
.toBe(true);
}
test("Terminals tab creates multiple terminals and streams command output", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminals-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page);
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000,
});
const preEnterEchoMarker = shortTerminalMarker("typed");
await runTerminalCommandWithPreEnterEcho(
page,
`echo ${preEnterEchoMarker}`,
preEnterEchoMarker
);
const ansiMarker = `ansi-red-${Date.now()}`;
await runTerminalCommand(
page,
`printf '\\033[31m${ansiMarker}\\033[0m\\n'`,
ansiMarker
);
await expectAnsiColorApplied(page, ansiMarker);
const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerTwo}`, markerTwo);
} finally {
await repo.cleanup();
}
});
test("new terminal does not inherit output from the previously selected terminal", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-output-isolation-");
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: "hello",
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: repo.path,
});
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
await page.getByTestId("workspace-new-terminal-tab").first().click();
await waitForTerminalAttachToSettle(page);
const firstMarker = `terminal-isolation-one-${Date.now()}`;
await runTerminalCommandAndWaitForBuffer(page, `echo ${firstMarker}`, firstMarker);
await page.getByTestId("workspace-new-terminal-tab").first().click();
await waitForTerminalAttachToSettle(page);
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
const secondMarker = `terminal-isolation-two-${Date.now()}`;
await runTerminalCommandAndWaitForBuffer(page, `echo ${secondMarker}`, secondMarker);
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
} finally {
await repo.cleanup();
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
await runTerminalCommand(
page,
"for i in $(seq 1 12000); do echo reattach-$i; done",
"reattach-12000"
);
for (let attempt = 0; attempt < 4; attempt += 1) {
await openFilesPanel(page);
await openTerminalsPanel(page);
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
timeout: 30000,
});
}
const marker = `reattach-health-${Date.now()}`;
await runTerminalCommand(page, `echo ${marker}`, marker);
} finally {
await repo.cleanup();
}
});
test("mobile terminal tab switch keeps command input routed to the selected tab", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-mobile-routing-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal routing");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerTabsVisible(page);
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click({ force: true });
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
timeout: 30000,
});
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
timeout: 30000,
});
await waitForTerminalAttachToSettle(page);
await visibleTestId(page, "terminals-create-button").click();
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(2);
const firstTab = tabs.first();
const secondTab = tabs.nth(1);
const firstTabId = await firstTab.getAttribute("data-testid");
const secondTabId = await secondTab.getAttribute("data-testid");
if (!firstTabId || !secondTabId) {
throw new Error("Expected terminal tab IDs");
}
const firstMarker = `mobile-route-one-${Date.now()}`;
await firstTab.click();
await visibleTestId(page, "terminal-surface").click({ force: true });
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
await page.keyboard.press("Enter");
const secondMarker = `mobile-route-two-${Date.now()}`;
await secondTab.click();
await visibleTestId(page, "terminal-surface").click({ force: true });
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
await page.keyboard.press("Enter");
await page.locator(`[data-testid="${firstTabId}"]:visible`).first().click();
await waitForTerminalAttachToSettle(page);
await expectCurrentTerminalBufferToContain(page, firstMarker);
await expectCurrentTerminalBufferNotToContain(page, secondMarker);
await page.locator(`[data-testid="${secondTabId}"]:visible`).first().click();
await waitForTerminalAttachToSettle(page);
await expectCurrentTerminalBufferToContain(page, secondMarker);
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
} finally {
await repo.cleanup();
}
});
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
for (let iteration = 0; iteration < 40; iteration += 1) {
await page.keyboard.press("Enter");
}
const markerAfterEnters = shortTerminalMarker("visible");
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterEnters, {
timeout: 30000,
});
const longSuffix = "x".repeat(120);
await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 });
for (let iteration = 0; iteration < longSuffix.length; iteration += 1) {
await page.keyboard.press("Backspace");
}
const markerAfterBackspace = shortTerminalMarker("backspace");
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(
"printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'",
{ delay: 0 }
);
await page.keyboard.press("Enter");
const marker = `post-alt-screen-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal tab is removed when shell exits", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal exit flow");
await openTerminalsPanel(page);
const exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 });
await page.keyboard.press("Enter");
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
timeout: 30000,
});
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000,
});
const nextTabTestId = await getFirstTerminalTabTestId(page);
expect(nextTabTestId).not.toBe(exitedTabTestId);
} finally {
await repo.cleanup();
}
});
test("closing terminal with running command asks for confirmation", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-close-confirm-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal close confirmation");
await openTerminalsPanel(page);
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-${Date.now()}`;
await runTerminalCommand(
page,
`echo ${runningMarker} && sleep 30`,
runningMarker
);
await tab.hover();
const dialogPromise = page.waitForEvent("dialog", { timeout: 30000 }).then(
async (dialog) => {
expect(dialog.type()).toBe("confirm");
await dialog.dismiss();
}
);
await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise;
await expect(tab).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("confirming terminal close with running command removes the tab", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-close-confirm-accept-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal close confirmation accept");
await openTerminalsPanel(page);
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
await runTerminalCommand(
page,
`echo ${runningMarker} && sleep 30`,
runningMarker
);
await tab.hover();
const dialogPromise = page.waitForEvent("dialog", { timeout: 30000 }).then(
async (dialog) => {
expect(dialog.type()).toBe("confirm");
await dialog.accept();
}
);
await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise;
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent one");
const first = parseAgentFromUrl(page.url());
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent two");
const second = parseAgentFromUrl(page.url());
expect(first.serverId).toBe(second.serverId);
expect(first.agentId).not.toBe(second.agentId);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
const sharedMarker = `shared-terminal-${Date.now()}`;
await runTerminalCommand(page, `echo ${sharedMarker}`, sharedMarker);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal key combo capture");
await openTerminalsPanel(page);
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("cat -v", { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText("cat -v", { timeout: 30000 });
await page.keyboard.press("Escape");
await expect(surface).toContainText("^[", { timeout: 30000 });
await page.keyboard.press("Control+C");
await expect(surface).toContainText("^C", { timeout: 30000 });
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
// Clear any line-editor residue before validating the next shell command.
await page.keyboard.press("Enter");
const marker = shortTerminalMarker("key-capture");
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(false);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
return typeof terminal?.rows === "number" ? terminal.rows : 0;
});
}
async function setTerminalHeightInset(page: Page, inset: number): Promise<void> {
await page.evaluate((nextPadding) => {
const surfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
);
const activeSurface =
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
if (!activeSurface) {
return;
}
const host = activeSurface.parentElement as HTMLElement | null;
if (!host) {
return;
}
if (nextPadding > 0) {
host.style.flex = "0 0 auto";
host.style.height = `calc(100% - ${nextPadding}px)`;
return;
}
host.style.flex = "1 1 auto";
host.style.height = "100%";
}, inset);
}
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (
window as {
__paseoTerminal?: {
buffer?: { active?: { baseY?: unknown; viewportY?: unknown } };
};
}
).__paseoTerminal;
const baseY = terminal?.buffer?.active?.baseY;
const viewportY = terminal?.buffer?.active?.viewportY;
if (typeof baseY !== "number" || typeof viewportY !== "number") {
return 0;
}
return Math.max(0, baseY - viewportY);
});
}
test("terminal viewport resizes and uses xterm scrollback", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-viewport-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Viewport and scrollback test");
await openTerminalsPanel(page);
const initialViewport = page.viewportSize();
if (!initialViewport) {
throw new Error("Expected a viewport size");
}
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThan(0);
const initialRows = await getTerminalRows(page);
await setTerminalHeightInset(page, 220);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await setTerminalHeightInset(page, 0);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const reducedHeight = Math.max(520, initialViewport.height - 220);
await page.setViewportSize({
width: initialViewport.width,
height: reducedHeight,
});
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await page.setViewportSize(initialViewport);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const scrollbackMarker = `scrollback-${Date.now()}`;
await runTerminalCommand(
page,
`for i in $(seq 1 180); do echo ${scrollbackMarker}-$i; done`,
`${scrollbackMarker}-180`
);
const surface = visibleTestId(page, "terminal-surface");
await surface.hover();
await page.mouse.wheel(0, -3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeGreaterThan(0);
const distanceAfterScrollUp = await getTerminalScrollbackDistance(page);
await surface.hover();
await page.mouse.wheel(0, 3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeLessThan(distanceAfterScrollUp);
} finally {
await repo.cleanup();
}
});

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

View File

@@ -4,6 +4,8 @@ const fs = require("fs");
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 ?? "")
@@ -13,8 +15,21 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
config.transformer.asyncRequireModulePath = require.resolve(
"@expo/metro-config/build/async-require"
const escapedAppSrcRoot = appSrcRoot
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
.join("[\\\\/]");
const pathSeparatorPattern = "[\\\\/]";
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
react: path.join(appNodeModulesRoot, "react"),
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
"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) {

View File

@@ -1,16 +1,18 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.17",
"version": "0.1.30",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:prod": "npm run android:production",
"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: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",
@@ -23,16 +25,16 @@
"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",
"@getpaseo/expo-two-way-audio": "0.1.30",
"@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.17",
"@getpaseo/server": "0.1.30",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -50,7 +52,9 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
@@ -84,8 +88,8 @@
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.4",
"react-dom": "19.1.4",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
@@ -93,7 +97,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "^0.30.0",
"react-native-nitro-modules": "0.33.8",
"react-native-permissions": "^5.4.2",
"react-native-popover-view": "^6.1.0",
"react-native-reanimated": "~4.1.1",

View File

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

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,67 +0,0 @@
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
// Ensure Unistyles runs before Expo Router statically renders each page.
import "../styles/unistyles";
const webEcosystemStyles = /* css */ `
html {
touch-action: auto;
}
body {
overflow: auto;
overscroll-behavior: contain;
-webkit-user-select: text;
user-select: text;
}
body * {
-webkit-user-select: text;
user-select: text;
}
[data-testid="sidebar-agent-list-scroll"],
[data-testid="agent-chat-scroll"],
[data-testid="git-diff-scroll"],
[data-testid="file-explorer-tree-scroll"] {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
function WebRespectfulStyleReset() {
return (
<style
id="paseo-web-ecosystem"
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
/>
);
}
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
/>
{/* Reset scroll styles so React Native Web views behave like native. */}
<ScrollViewStyleReset />
<WebRespectfulStyleReset />
</head>
<body>{children}</body>
</html>
);
}

View File

@@ -1,6 +1,12 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -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,
useHostRuntimeSession,
} 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";
@@ -34,7 +55,9 @@ import {
} from "@/contexts/horizontal-scroll-context";
import { getIsTauri } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -45,24 +68,17 @@ import {
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
import { attachConsole } from "@/utils/tauri-attach-console";
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);
}
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -71,20 +87,39 @@ 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"
);
});
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
}
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(
@@ -140,6 +175,79 @@ function PushNotificationRouter() {
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { client } = useHostRuntimeSession(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>;
}
@@ -147,11 +255,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
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 daemons = useHosts();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
@@ -161,7 +274,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = daemons.length > 0;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
@@ -223,10 +336,6 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
@@ -243,13 +352,6 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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)();
@@ -285,7 +387,9 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
@@ -303,8 +407,9 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
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(() => {
@@ -323,7 +428,8 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
{children}
</VoiceProvider>
);
@@ -373,6 +479,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -391,10 +499,35 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
}, [params.open, pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
<AppContainer
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
chromeEnabled={shouldShowAppChrome}
>
{children}
</AppContainer>
);
}
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
@@ -448,52 +581,61 @@ function MissingDaemonView() {
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<PerfDiagnosticsProvider scope="root_layout">
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
}}
>
<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]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</PerfDiagnosticsProvider>
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<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]"
getId={({ params }) =>
`${params?.serverId}:${params?.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>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</GestureHandlerRootView>
);
}

View File

@@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
@@ -15,10 +16,13 @@ export default function HostIndexRoute() {
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
);
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
);
useEffect(() => {
if (preferencesLoading) {
@@ -37,14 +41,21 @@ export default function HostIndexRoute() {
}
const visibleAgents = sessionAgents
? Array.from(sessionAgents.values()).filter(
(agent) => !agent.archivedAt
)
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
);
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
return rightTime - leftTime;
});
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
@@ -57,21 +68,23 @@ export default function HostIndexRoute() {
return;
}
const preferredWorkingDir =
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
const workspaceId = preferredWorkingDir || ".";
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
return;
}
router.replace(buildHostOpenProjectRoute(serverId) as any);
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [
pathname,
preferences.serverId,
preferences.workingDir,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
return null;

View File

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

View File

@@ -1,16 +1,22 @@
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { useLocalSearchParams } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
parseHostWorkspaceRouteFromPathname,
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 params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
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(params.open) ? params.open[0] : params.open
const openIntent = parseWorkspaceOpenIntent(openValue)

View File

@@ -1,75 +1,113 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useLocalSearchParams, usePathname, 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 { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostRootRoute } from "@/utils/host-routes";
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { 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 { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = 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 = shouldUseManagedDesktopDaemon()
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 (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
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) {
return <DraftAgentScreen />;
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

@@ -7,20 +7,20 @@ 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"
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

@@ -65,7 +65,7 @@ export function AddHostMethodModal({
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
<Text style={styles.optionSubtext}>Local network or Tailscale (unencrypted).</Text>
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
</View>
</Pressable>
@@ -74,7 +74,7 @@ export function AddHostMethodModal({
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
</View>
</Pressable>
) : null}
@@ -83,7 +83,7 @@ export function AddHostMethodModal({
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
</View>
</Pressable>
</AdaptiveModalSheet>

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";
@@ -103,13 +104,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection was refused. Is the daemon running on that host and port?";
detail = "Connection refused. Is the server running at this address?";
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -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,7 +181,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true);
setErrorMessage("");
const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint });
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);
@@ -217,7 +224,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
@@ -225,7 +232,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
placeholder="host:6767"
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"

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

@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import {
deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
@@ -39,7 +40,6 @@ import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
import { shouldClearAgentAttention } from '@/utils/agent-attention'
type QueuedMessage = {
id: string
@@ -66,11 +66,16 @@ interface AgentInputAreaProps {
commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void
onComposerHeightChange?: (height: number) => void
onAttentionInputFocus?: () => void
onAttentionPromptSend?: () => void
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps
}
const EMPTY_ARRAY: readonly QueuedMessage[] = []
const DESKTOP_MESSAGE_PLACEHOLDER = 'Message the agent, tag @files, or use /commands and /skills'
const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands'
export function AgentInputArea({
agentId,
@@ -85,6 +90,9 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
@@ -125,6 +133,9 @@ export function AgentInputArea({
Platform.OS === 'web' &&
UnistylesRuntime.breakpoint !== 'xs' &&
UnistylesRuntime.breakpoint !== 'sm'
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER
const userInput = value ?? internalInput
const setUserInput = onChangeText ?? setInternalInput
const [cursorIndex, setCursorIndex] = useState(0)
@@ -241,18 +252,9 @@ export function AgentInputArea({
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
if (
shouldClearAgentAttention({
agentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
client.clearAgentAttention(agentId)
}
onAttentionPromptSend?.()
}
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead])
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage
@@ -389,16 +391,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,
})
})
)
@@ -596,7 +606,7 @@ export function AgentInputArea({
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false
const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected) {
if (!voice || !isConnected || !agent) {
return
}
if (voice.isVoiceSwitching) {
@@ -613,7 +623,7 @@ export function AgentInputArea({
toast.error(message)
}
})
}, [agentId, isConnected, serverId, toast, voice])
}, [agent, agentId, isConnected, serverId, toast, voice])
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id)
@@ -689,7 +699,7 @@ export function AgentInputArea({
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={theme.iconSize.lg} color="white" fill="white" />
<Square size={theme.iconSize.md} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -703,22 +713,23 @@ export function AgentInputArea({
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent ? (
{!isVoiceModeForAgent && agent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
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={theme.iconSize.md} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -806,7 +817,7 @@ export function AgentInputArea({
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder="Message agent..."
placeholder={messagePlaceholder}
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
@@ -822,7 +833,13 @@ export function AgentInputArea({
onSelectionChange={(selection) => {
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
onFocusChange={(focused) => {
setIsMessageInputFocused(focused)
if (focused) {
onAttentionInputFocus?.()
}
}}
onHeightChange={onComposerHeightChange}
/>
</View>
</View>
@@ -869,8 +886,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',
@@ -882,12 +899,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',
},
@@ -895,6 +909,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

@@ -4,332 +4,347 @@ import {
Pressable,
Modal,
RefreshControl,
SectionList,
type ViewToken,
type SectionListRenderItem,
} 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQueryClient } from "@tanstack/react-query";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
} from "@/runtime/host-runtime";
import { AgentStatusDot } from "@/components/agent-status-dot";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
useCheckoutStatusCacheOnly,
} from "@/hooks/use-checkout-status-query";
import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
import {
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
FlatList,
type ListRenderItem,
} 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 { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
interface AgentListProps {
agents: AggregatedAgent[];
showCheckoutInfo?: boolean;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
agents: AggregatedAgent[]
showCheckoutInfo?: boolean
isRefreshing?: boolean
onRefresh?: () => void
selectedAgentId?: string
onAgentSelect?: () => void
listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
}
interface AgentListSection {
key: string;
title: string;
data: AggregatedAgent[];
key: string
title: string
data: AggregatedAgent[]
}
function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const activityStart = new Date(
lastActivityAt.getFullYear(),
lastActivityAt.getMonth(),
lastActivityAt.getDate()
);
)
if (activityStart.getTime() >= todayStart.getTime()) {
return "Today";
return 'Today'
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return "Yesterday";
return 'Yesterday'
}
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const diffTime = todayStart.getTime() - activityStart.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
if (diffDays <= 7) {
return "This week";
return 'This week'
}
if (diffDays <= 30) {
return "This month";
return 'This month'
}
return "Older";
return 'Older'
}
interface AgentListRowProps {
agent: AggregatedAgent;
selectedAgentId?: string;
showCheckoutInfo: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
function formatStatusLabel(status: AggregatedAgent['status']): string {
switch (status) {
case 'initializing':
return 'Starting'
case 'idle':
return 'Idle'
case 'running':
return 'Running'
case 'error':
return 'Error'
case 'closed':
return 'Closed'
default:
return status
}
}
function AgentListRow({
function SessionBadge({
label,
tone = 'neutral',
}: {
label: string
tone?: 'neutral' | 'warning' | 'danger'
}) {
return (
<View
style={[
styles.badge,
tone === 'warning' && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger,
]}
>
<Text
style={[
styles.badgeText,
tone === 'warning' && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
)
}
function SessionRow({
agent,
isMobile,
selectedAgentId,
showCheckoutInfo,
showAttentionIndicator,
onPress,
onLongPress,
}: AgentListRowProps) {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const archivedLabel = agent.archivedAt ? "Archived" : null;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const projectPath = showCheckoutInfo
? deriveProjectPath(agent.cwd, checkout)
: agent.cwd;
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
}: {
agent: AggregatedAgent
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void
}) {
const timeAgo = formatTimeAgo(agent.lastActivityAt)
const agentKey = `${agent.serverId}:${agent.id}`
const isSelected = selectedAgentId === agentKey
const statusLabel = formatStatusLabel(agent.status)
const projectPath = shortenPath(agent.cwd)
return (
<Pressable
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
styles.row,
isSelected && styles.rowSelected,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
]}
onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""}
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo}
<View style={styles.rowLeading}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
</View>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || 'New session'}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
) : null}
</View>
{isMobile && (
<View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
</View>
{!isMobile && (
<>
<Text style={styles.columnMeta} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
</View>
) : null}
</Pressable>
);
)
}
function SessionTableSection({
section,
isMobile,
selectedAgentId,
showAttentionIndicator,
onAgentPress,
onAgentLongPress,
}: {
section: AgentListSection
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onAgentPress: (agent: AggregatedAgent) => void
onAgentLongPress: (agent: AggregatedAgent) => void
}) {
return (
<View style={styles.sectionBlock}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
<View style={styles.listCard}>
{section.data.map((agent, index) => (
<View
key={`${agent.serverId}:${agent.id}`}
style={index > 0 ? styles.rowDivider : undefined}
>
<SessionRow
agent={agent}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onPress={onAgentPress}
onLongPress={onAgentLongPress}
/>
</View>
))}
</View>
</View>
)
}
export function AgentList({
agents,
showCheckoutInfo = true,
isRefreshing = false,
onRefresh,
selectedAgentId,
onAgentSelect,
listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const { theme } = useUnistyles()
const pathname = usePathname()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null
)
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const isActionSheetVisible = actionAgent !== null
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
const handleAgentPress = useCallback(
(agent: AggregatedAgent) => {
if (isActionSheetVisible) {
return;
return
}
const serverId = agent.serverId;
const agentId = agent.id;
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
to: "agent",
params: { serverId, agentId },
});
const serverId = agent.serverId
const agentId = agent.id
const shouldReplace = pathname.startsWith('/h/')
const navigate = shouldReplace ? router.replace : router.push
const shouldReplace = pathname.startsWith("/h/");
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.()
onAgentSelect?.();
const route: Href = buildHostWorkspaceAgentRoute(
serverId,
agent.cwd,
agentId
) as Href;
navigate(route);
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
navigate(route)
},
[isActionSheetVisible, pathname, onAgentSelect]
);
)
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent);
}, []);
setActionAgent(agent)
}, [])
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
}, []);
setActionAgent(null)
}, [])
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !actionClient) {
return;
return
}
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, actionClient]);
const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }),
[]
);
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
if (!showCheckoutInfo) {
return;
}
for (const token of viewableItems) {
const agent = token.item as AggregatedAgent | undefined;
if (!agent) {
continue;
}
const runtime = getHostRuntimeStore();
const client = runtime.getClient(agent.serverId);
const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId));
if (!client || !isConnected) {
continue;
}
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
const queryState = queryClient.getQueryState(queryKey);
const isFetching = queryState?.fetchStatus === "fetching";
const isFresh =
typeof queryState?.dataUpdatedAt === "number" &&
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
if (isFetching || isFresh) {
continue;
}
void queryClient.prefetchQuery({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
});
}
},
[queryClient, showCheckoutInfo]
);
void actionClient.archiveAgent(actionAgent.id)
setActionAgent(null)
}, [actionAgent, actionClient])
const sections = useMemo((): AgentListSection[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
const buckets = new Map<string, AggregatedAgent[]>()
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
existing.push(agent);
buckets.set(label, existing);
const label = deriveDateSectionLabel(agent.lastActivityAt)
const existing = buckets.get(label) ?? []
existing.push(agent)
buckets.set(label, existing)
}
const result: AgentListSection[] = [];
const result: AgentListSection[] = []
for (const label of order) {
const data = buckets.get(label);
const data = buckets.get(label)
if (!data || data.length === 0) {
continue;
continue
}
result.push({ key: `date:${label}`, title: label, data });
result.push({ key: `date:${label}`, title: label, data })
}
return result;
}, [agents]);
return result
}, [agents])
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> =
useCallback(
({ item: agent }) => (
<AgentListRow
agent={agent}
selectedAgentId={selectedAgentId}
showCheckoutInfo={showCheckoutInfo}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
),
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
);
const renderSectionHeader = useCallback(
({ section }: { section: AgentListSection }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
const renderSection: ListRenderItem<AgentListSection> = useCallback(
({ item: section }) => (
<SessionTableSection
section={section}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onAgentPress={handleAgentPress}
onAgentLongPress={handleAgentLongPress}
/>
),
[]
);
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
)
const keyExtractor = useCallback(
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
[]
);
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
return (
<>
<SectionList
sections={sections}
<FlatList
data={sections}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderAgentItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
extraData={selectedAgentId}
renderItem={renderSection}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={12}
windowSize={7}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={16}
removeClippedSubviews={true}
ListFooterComponent={listFooterComponent}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
refreshControl={
onRefresh ? (
<RefreshControl
@@ -349,16 +364,16 @@ export function AgentList({
onRequestClose={handleCloseActionSheet}
>
<View style={styles.sheetOverlay}>
<Pressable
style={styles.sheetBackdrop}
onPress={handleCloseActionSheet}
/>
<View style={[styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }]}>
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
<View
style={[
styles.sheetContainer,
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
]}
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
? "Host offline"
: "Archive this agent?"}
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -388,7 +403,7 @@ export function AgentList({
</View>
</Modal>
</>
);
)
}
const styles = StyleSheet.create((theme) => ({
@@ -397,83 +412,172 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
listContent: {
paddingHorizontal: theme.spacing[4],
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
paddingBottom: theme.spacing[6],
gap: theme.spacing[1],
},
sectionHeader: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
sectionBlock: {
marginTop: theme.spacing[2],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[1],
marginBottom: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: "500",
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textAlign: "left",
},
agentItem: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
listCard: {
overflow: {
xs: 'hidden' as const,
md: 'visible' as const,
},
borderRadius: {
xs: theme.borderRadius.lg,
md: 0,
},
},
agentItemSelected: {
backgroundColor: theme.colors.surface2,
},
agentItemHovered: {
backgroundColor: theme.colors.surface1,
},
agentItemPressed: {
backgroundColor: theme.colors.surface2,
},
agentContent: {
flex: 1,
gap: theme.spacing[0],
rowDivider: {
borderTopWidth: {
xs: StyleSheet.hairlineWidth,
md: 0,
},
borderTopColor: theme.colors.border,
},
row: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: {
xs: theme.borderRadius.lg,
md: 0,
},
marginBottom: {
xs: theme.spacing[1],
md: 0,
},
},
rowLeading: {
marginRight: theme.spacing[3],
},
rowContent: {
flex: 1,
minWidth: 0,
},
rowTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing[2],
},
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.8,
rowMetaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing[1],
marginTop: 2,
},
agentTitleHighlighted: {
rowTrailing: {
marginLeft: theme.spacing[2],
},
rowSelected: {
backgroundColor: theme.colors.surface2,
},
rowHovered: {
backgroundColor: theme.colors.surface1,
},
rowPressed: {
backgroundColor: theme.colors.surface2,
},
sessionTitle: {
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: '500',
color: theme.colors.foreground,
opacity: 0.86,
},
sessionTitleHighlighted: {
opacity: 1,
},
secondaryRow: {
sessionMetaText: {
maxWidth: '100%',
fontSize: theme.fontSize.sm,
fontWeight: "300",
color: theme.colors.foregroundMuted,
},
sessionMetaSeparator: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
opacity: 0.7,
},
columnMeta: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 60,
maxWidth: 200,
marginLeft: theme.spacing[4],
},
columnMetaFixed: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 72,
textAlign: 'right' as const,
},
badge: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)',
},
badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)',
},
badgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
badgeTextWarning: {
color: theme.colors.palette.amber[500],
},
badgeTextDanger: {
color: theme.colors.palette.red[300],
},
sheetOverlay: {
flex: 1,
justifyContent: "flex-end",
justifyContent: 'flex-end',
},
sheetBackdrop: {
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(0,0,0,0.35)",
backgroundColor: 'rgba(0,0,0,0.35)',
},
sheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
gap: theme.spacing[4],
},
sheetHandle: {
alignSelf: "center",
alignSelf: 'center',
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
@@ -484,18 +588,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
textAlign: 'center',
},
sheetButtonRow: {
flexDirection: "row",
flexDirection: 'row',
gap: theme.spacing[3],
},
sheetButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4],
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
sheetArchiveButton: {
backgroundColor: theme.colors.primary,
@@ -516,4 +620,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}));
}))

View File

@@ -1,7 +1,17 @@
import { useMemo, useState } from 'react'
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 {
Bot,
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
SlidersHorizontal,
} 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,6 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import type {
AgentMode,
@@ -17,6 +28,11 @@ import type {
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
type StatusOption = {
@@ -25,6 +41,7 @@ type StatusOption = {
}
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
@@ -52,6 +69,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
@@ -71,7 +91,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,
@@ -90,7 +139,12 @@ function ControlledStatusBar({
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const dropdownMaxWidth = isWeb ? 360 : undefined
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null)
const modelAnchorRef = useRef<View>(null)
const thinkingAnchorRef = useRef<View>(null)
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
@@ -107,6 +161,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) ||
@@ -119,18 +178,64 @@ function ControlledStatusBar({
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
const SEARCH_THRESHOLD = 6
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[providerOptions]
)
const comboboxModeOptions = useMemo<ComboboxOption[]>(
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modeOptions]
)
const comboboxModelOptions = useMemo<ComboboxOption[]>(
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions]
)
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[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) => {
setOpenSelector(nextOpen ? selector : null)
},
[]
)
return (
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
<View style={styles.container}>
{isWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
<>
<Pressable
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
style={({ pressed, hovered, open }) => [
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -139,135 +244,123 @@ function ControlledStatusBar({
>
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-provider-menu"
>
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
<Combobox
options={comboboxProviderOptions}
value={selectedProviderId ?? ''}
onSelect={(id) => onSelectProvider?.(id)}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
anchorRef={providerAnchorRef}
desktopPlacement="top-start"
/>
</>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
<>
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-mode-menu"
>
{modeOptions.map((mode) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
>
{mode.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ''}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && 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} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-model-menu"
>
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{thinkingOptions && thinkingOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed, hovered, open }) => [
{canSelectModel ? (
<>
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(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>
<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"
accessibilityLabel={`Select thinking option (${displayThinking})`}
testID="agent-thinking-selector"
>
<Brain
size={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
style={{ marginTop: 1 }}
/>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-thinking-menu"
>
{thinkingOptions.map((thinking) => (
<DropdownMenuItem
key={thinking.id}
selected={thinking.id === selectedThinkingOptionId}
onSelect={() => onSelectThinkingOption?.(thinking.id)}
>
{thinking.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
onSelect={(id) => onSelectThinkingOption?.(id)}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
/>
</>
) : null}
</>
) : (
@@ -282,7 +375,7 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
<SlidersHorizontal size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable>
<AdaptiveModalSheet
@@ -337,17 +430,57 @@ 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>
<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>
@@ -355,36 +488,6 @@ 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>
@@ -486,6 +589,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
return (
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
@@ -537,33 +641,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])
@@ -572,8 +669,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)}
@@ -595,18 +729,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,
},
@@ -622,8 +764,8 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
prefsButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
describe("buildAgentStreamRenderModel", () => {
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
const seed = index * 2;
tail.push(userMessage(`u${index}`, seed + 1));
tail.push(assistantMessage(`a${index}`, seed + 2));
}
const head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyVirtualized.length).toBeGreaterThan(0);
expect(model.segments.historyMounted.length).toBeGreaterThan(0);
expect(model.segments.liveHead.map((item) => item.id)).toEqual(["live-a"]);
expect(model.history).not.toContain(head[0]);
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: true,
});
expect(model.segments.historyVirtualized).toHaveLength(0);
expect(model.segments.historyMounted).toBe(tail);
expect(model.segments.liveHead).toBe(head);
});
it("reuses ordered committed history when only the live head changes", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const firstHead = [assistantMessage("live-a", 3)];
const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({
tail,
head: firstHead,
platform: "native",
isMobileBreakpoint: false,
});
const second = buildAgentStreamRenderModel({
tail,
head: secondHead,
platform: "native",
isMobileBreakpoint: false,
});
expect(first.history).toBe(second.history);
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
});
});

View File

@@ -0,0 +1,178 @@
import type { ReactNode } from "react";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
} from "./stream-strategy";
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
};
export type StreamHistoryBoundary = {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
};
export type StreamRenderAuxiliary = {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
};
export type AgentStreamRenderModel = {
history: StreamItem[];
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
};
export type BuildAgentStreamRenderModelInput = {
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
workingIndicator: null,
};
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const orderedHeadCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const splitHistoryCache = new WeakMap<
StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
source: StreamItem[];
cacheKey: string;
order: (items: StreamItem[]) => StreamItem[];
}): StreamItem[] {
const { cache, source, cacheKey, order } = params;
let cachedByKey = cache.get(source);
if (!cachedByKey) {
cachedByKey = new Map();
cache.set(source, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
const ordered = order(source);
cachedByKey.set(cacheKey, ordered);
return ordered;
}
function splitOrderedTail(params: {
orderedTail: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}): Pick<AgentStreamRenderModel, "history" | "segments"> {
const { orderedTail, platform, isMobileBreakpoint } = params;
const shouldSplitHistory =
platform === "web" &&
!isMobileBreakpoint &&
orderedTail.length > getWebPartialVirtualizationThreshold();
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
let cachedByKey = splitHistoryCache.get(orderedTail);
if (!cachedByKey) {
cachedByKey = new Map();
splitHistoryCache.set(orderedTail, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
if (!shouldSplitHistory) {
const unsplit = {
history: orderedTail,
segments: {
historyVirtualized: EMPTY_STREAM_ITEMS,
historyMounted: orderedTail,
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, unsplit);
return unsplit;
}
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
});
const split = {
history: orderedTail,
segments: {
historyVirtualized: orderedTail.slice(0, mountedWindowStart),
historyMounted: orderedTail.slice(mountedWindowStart),
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, split);
return split;
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: input.tail,
cacheKey: orderingCacheKey,
order: (items) =>
orderTailForStreamRenderStrategy({
strategy,
streamItems: items,
}),
});
const orderedHead = getOrderedItems({
cache: orderedHeadCache,
source: input.head,
cacheKey: orderingCacheKey,
order: (items) =>
orderHeadForStreamRenderStrategy({
strategy,
streamHead: items,
}),
});
const splitHistory = splitOrderedTail({
orderedTail,
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
return {
history: splitHistory.history,
segments: {
...splitHistory.segments,
liveHead: orderedHead,
},
boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};
}

View File

@@ -9,6 +9,7 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
it("uses inverted_stream on native", () => {
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});

View File

@@ -1,438 +1,2 @@
import type { ComponentType, ReactElement, RefObject } from "react";
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
};
export type StreamNearBottomInput = StreamViewportMetrics & {
offsetY: number;
threshold: number;
};
export type StreamEdgeSlotProps = {
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<any> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<StreamItem> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export interface StreamRenderStrategy {
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
}
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
};
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
function scrollAnchorIntoView(params: {
refs: StreamRenderRefs;
animated: boolean;
}): boolean {
const anchorHandle = params.refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
object)
| null;
if (!anchorHandle) {
return false;
}
const maybeNative =
typeof anchorHandle.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle;
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
if (typeof domElement.scrollIntoView !== "function") {
return false;
}
domElement.scrollIntoView({
block: "end",
behavior: params.animated ? "smooth" : "auto",
});
return true;
}
function forceScrollContainerToBottom(
refs: StreamRenderRefs,
fallbackOffset: number
): void {
const resolveNode = (
input: unknown
): HTMLElement | null => {
if (!(input instanceof HTMLElement)) {
return null;
}
if (input.scrollHeight - input.clientHeight > 1) {
return input;
}
let node: HTMLElement | null = input.parentElement;
while (node) {
if (node.scrollHeight - node.clientHeight > 1) {
return node;
}
node = node.parentElement;
}
return null;
};
const scrollViewHandle = refs.scrollViewRef.current as
| {
getNativeScrollRef?: () => unknown;
getScrollableNode?: () => unknown;
getInnerViewNode?: () => unknown;
getNativeRef?: () => unknown;
}
| null;
const anchorHandle = refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown } & object)
| null;
const candidates: unknown[] = [
scrollViewHandle?.getNativeScrollRef?.(),
scrollViewHandle?.getScrollableNode?.(),
scrollViewHandle?.getInnerViewNode?.(),
scrollViewHandle?.getNativeRef?.(),
scrollViewHandle,
typeof anchorHandle?.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle,
];
let scrollNode: HTMLElement | null = null;
for (const candidate of candidates) {
scrollNode = resolveNode(candidate);
if (scrollNode) {
break;
}
}
if (!scrollNode && typeof document !== "undefined") {
scrollNode = resolveNode(
document.querySelector("[data-testid='agent-chat-scroll']")
);
}
if (!scrollNode) {
return;
}
const snap = () => {
scrollNode.scrollTop = Math.max(
fallbackOffset,
scrollNode.scrollHeight - scrollNode.clientHeight
);
};
snap();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(snap);
}
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy {
return {
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
scrollToBottom: (params) => config.scrollToBottom(params),
scrollToOffset: (params) => config.scrollToOffset(params),
};
}
function createInvertedStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
scrollToBottom: ({ refs, animated }) => {
refs.flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.flatListRef.current?.scrollToOffset({ offset, animated });
},
});
}
function createForwardStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight -
(inputMetrics.offsetY + inputMetrics.viewportHeight)
);
return distanceFromBottom <= inputMetrics.threshold;
},
getBottomOffset: (metrics) =>
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
scrollToBottom: ({ refs, metrics, animated }) => {
const bottomOffset = Math.max(
0,
metrics.contentHeight - metrics.viewportHeight
);
const usedAnchor = scrollAnchorIntoView({ refs, animated });
if (!usedAnchor) {
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
}
// Always apply deterministic bottom offset to avoid partial anchors.
refs.scrollViewRef.current?.scrollTo?.({
y: bottomOffset,
animated,
});
forceScrollContainerToBottom(refs, bottomOffset);
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
): StreamRenderStrategy {
if (input.platform === "web") {
return createForwardStreamStrategy();
}
return createInvertedStreamStrategy();
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
strategy: StreamRenderStrategy;
index: number;
relation: NeighborRelation;
}): number {
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamRenderStrategy;
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
export * from "./stream-strategy";
export * from "./agent-stream-render-model";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function toolCall(id: string, seed: number): StreamItem {
return {
kind: "tool_call",
id,
timestamp: createTimestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "test_tool",
arguments: {},
status: "completed",
},
},
};
}
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
return items.map((item, index) => ({ item, index }));
}
describe("findMountedWindowStart", () => {
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(0);
});
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 3;
items.push(userMessage(`u${index}`, seed + 1));
items.push(toolCall(`t${index}`, seed + 2));
items.push(assistantMessage(`a${index}`, seed + 3));
}
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(39);
});
});
describe("splitWebVirtualizedHistory", () => {
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 2;
items.push(userMessage(`u${index}`, seed + 1));
items.push(assistantMessage(`a${index}`, seed + 2));
}
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: 50,
});
expect(window.virtualizedEntries).toHaveLength(10);
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
});
describe("estimateStreamItemHeight", () => {
it("uses a larger estimate for user messages with image attachments", () => {
const item: StreamItem = {
kind: "user_message",
id: "u-image",
text: "image",
timestamp: createTimestamp(1),
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/screenshot.png",
fileName: "screenshot.png",
byteSize: 1024,
createdAt: Date.now(),
},
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
expect(getWebPartialVirtualizationThreshold()).toBe(6);
expect(getWebMountedRecentStreamItems()).toBe(4);
} finally {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
}
}
});
});

View File

@@ -0,0 +1,94 @@
import type { StreamItem } from "@/types/stream";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export type IndexedStreamItem = {
item: StreamItem;
index: number;
};
export type WebVirtualizedHistoryWindow = {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
};
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return 220;
case "tool_call":
return 136;
case "thought":
return 112;
case "todo_list":
return 144;
case "activity_log":
return 88;
case "compaction":
return 72;
default:
return 120;
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
minMountedCount: number;
}): WebVirtualizedHistoryWindow {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
mountedEntries: input.entries.slice(startIndex),
};
}

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,6 +7,7 @@ import {
View,
Platform,
} from "react-native";
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";
@@ -20,6 +21,37 @@ function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
type CommandCenterRowProps = {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
};
const CommandCenterRow = memo(function CommandCenterRow({
active,
children,
onPress,
registerRow,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
return (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
{children}
</Pressable>
);
});
export function CommandCenter() {
const { theme } = useUnistyles();
const {
@@ -33,7 +65,46 @@ export function CommandCenter() {
handleSelectItem,
} = useCommandCenter();
if (Platform.OS !== "web") return null;
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
useEffect(() => {
if (!open) {
return;
}
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
}, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
@@ -71,6 +142,7 @@ export function CommandCenter() {
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
@@ -105,14 +177,13 @@ export function CommandCenter() {
/>
) : null;
return (
<Pressable
<CommandCenterRow
key={`action:${action.id}`}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(index, el);
else rowRefs.current.delete(index);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
@@ -133,7 +204,7 @@ export function CommandCenter() {
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</Pressable>
</CommandCenterRow>
);
})}
</>
@@ -154,14 +225,13 @@ export function CommandCenter() {
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<Pressable
<CommandCenterRow
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
@@ -184,12 +254,12 @@ export function CommandCenter() {
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</Pressable>
</CommandCenterRow>
);
})}
</>
@@ -277,6 +347,8 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
textContent: {
flex: 1,
minWidth: 0,
gap: 2,
},
rowShortcut: {

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}

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