Compare commits

...

85 Commits

Author SHA1 Message Date
Mohamed Boudra
988c432ded fix(release): resolve android-v* tags to release tag in APK workflow
android-v0.1.32 must resolve to v0.1.32 for the release upload step,
otherwise it looks for a non-existent release and times out.
2026-03-23 15:59:21 +07:00
Mohamed Boudra
dce4e668f1 fix(release): set EP_GH_IGNORE_TIME to allow asset overwrites on rebuilds
electron-builder skips uploading to releases older than 2 hours.
This breaks all rebuild workflows since the release already exists.
2026-03-23 15:58:27 +07:00
Mohamed Boudra
2c69008a4a fix(desktop): use explicit artifact name for Linux to prevent scoped-name conflicts
The scoped package name @getpaseo/desktop was leaking into the tar.gz
artifact name. GitHub sanitizes the / to . on upload, causing a name
mismatch that prevents electron-builder's overwrite logic from finding
the existing asset on rebuild.
2026-03-23 15:53:34 +07:00
Mohamed Boudra
19452c2742 fix(release): add android retry tags and ban workflow_dispatch for rebuilds
workflow_dispatch checks out the tag ref, not main — so build fixes
on main never get picked up. Always use retry tags instead.
2026-03-23 15:49:38 +07:00
Mohamed Boudra
956828fa55 Fix terminal stream stalls after resize 2026-03-23 15:34:07 +07:00
Mohamed Boudra
b873523eab fix(terminal): skip resize snapshots and standardize key encoding 2026-03-23 14:47:27 +07:00
Mohamed Boudra
b862f7b72d fix(desktop): guard invalid badge count payloads 2026-03-23 14:42:17 +07:00
Mohamed Boudra
af0f629073 fix(website): update download links to match Electron asset filenames
The Electron migration changed the release asset naming convention from
Tauri's underscore format (Paseo_VERSION_ARCH.ext) to Electron's hyphen
format (Paseo-VERSION-arch.ext).
2026-03-23 14:36:31 +07:00
Mohamed Boudra
a4f433195d fix(terminal): only send resize from the focused client
Multiple clients viewing the same terminal would fight over PTY size
because every client sent resizes independently. Gate resize sends on
three focus levels: pane focus (split panes), screen focus (navigation),
and app visibility (browser window / mobile foreground). When a client
regains focus, clear the last reported size and trigger a reflow to
reclaim the PTY dimensions.
2026-03-23 13:31:07 +07:00
Mohamed Boudra
12ea75ab65 fix(app): add tooltip with shortcut hint to source control explorer toggle
The git checkout mode explorer button was missing the tooltip that
the non-git and mobile variants already had via HeaderToggleButton.
2026-03-23 13:25:41 +07:00
Mohamed Boudra
a6f24508e8 docs: add 0.1.32 release notes 2026-03-23 13:12:25 +07:00
Mohamed Boudra
d562fd40c0 Merge branch 'terminal-multiplexing-slot' 2026-03-23 13:05:20 +07:00
Mohamed Boudra
cded2c52ab feat(app): add focus mode (Cmd+Shift+F) to show only the active pane
Hides ScreenHeader, LeftSidebar, and ExplorerSidebar on desktop,
replacing the split layout with the single focused pane. Respects
macOS traffic light padding in the tab bar when active. Shortcut
is restricted to workspace screens. Also nudges traffic light
buttons up 3px globally.
2026-03-23 13:05:07 +07:00
Mohamed Boudra
df99442123 Fix terminal slot review issues 2026-03-23 12:44:18 +07:00
Mohamed Boudra
b9b1601bb8 fix(terminal): suppress DA response feedback loop after closing vim
Client-side xterm.js was generating Device Attributes responses via
onData, which fed back to the PTY as visible text. Register CSI handlers
to consume query responses on the client and respond to DA1 on the server.
2026-03-23 12:43:21 +07:00
Mohamed Boudra
5efac0dfaf fix(app): match sidebar diff stat colors to workspace header
Use theme palette colors instead of hardcoded muted hex values.
2026-03-23 12:36:54 +07:00
Mohamed Boudra
ea41dbc8e7 Add multiplexed terminal stream slots 2026-03-23 12:36:35 +07:00
Mohamed Boudra
c9ca1697e5 fix(server): free finalized message text in TimelineAssembler
TimelineAssembler.messages map retained full assistantText and
reasoningText for every message for the lifetime of the session.
Replace with a lightweight finalizedMessageIds set that prevents
duplicate emission during history replay without holding the text.
2026-03-23 12:29:23 +07:00
Mohamed Boudra
ed3ed78ec3 feat(app): add xterm addons and improve daemon memory reporting 2026-03-23 12:06:21 +07:00
Mohamed Boudra
37aaa7b155 feat(app): keep workspace tabs alive in panes 2026-03-23 12:03:02 +07:00
Mohamed Boudra
4b4e55a246 fix(workspace): suppress pane focus when moving tabs between panes 2026-03-23 11:30:35 +07:00
Mohamed Boudra
d6977352aa feat(terminal): capture cursor presentation modes (style, blink, hidden) 2026-03-23 10:58:39 +07:00
Mohamed Boudra
87145f1e6a Stabilize workspace stream render boundaries 2026-03-23 10:45:48 +07:00
Mohamed Boudra
7f9dafab68 refactor(cli): deduplicate command options between top-level and agent subcommands
Extract addXxxOptions() builder functions for all 9 shared commands
(ls, run, attach, logs, stop, delete, send, inspect, wait) so both
paseo <cmd> and paseo agent <cmd> use a single source of truth.

Fixes drift where agent subcommands were missing --wait-timeout (run),
--worktree/--base/--image (run), --since (logs), and --image (send).
2026-03-23 10:33:06 +07:00
Mohamed Boudra
07720f1d99 feat(keyboard): add Cmd+./Ctrl+. shortcut to toggle both sidebars
Reassign Cmd+. (Mac) and Ctrl+. (Win/Linux) from toggling the left
sidebar to toggling both sidebars at once. If either is open, both
close; if both are closed, both open. Left sidebar toggle on non-Mac
now uses Ctrl+B to match the Mac Cmd+B binding.
2026-03-23 10:06:38 +07:00
Mohamed Boudra
c0e63f76da feat(terminal): add shift+enter support and force resize on focus 2026-03-23 00:03:09 +07:00
Mohamed Boudra
93f4b84b8b Add terminal prod-ready e2e coverage 2026-03-22 23:17:26 +07:00
Mohamed Boudra
804d5d2487 Reorder status bar: permission trigger after thinking trigger 2026-03-22 23:13:48 +07:00
Mohamed Boudra
babc724220 Align terminal stream with snapshot-only attach 2026-03-22 22:49:05 +07:00
Mohamed Boudra
138c774297 Remove terminal output buffer leftovers 2026-03-22 22:31:57 +07:00
Mohamed Boudra
50ea5a76fb Simplify terminal streaming protocol 2026-03-22 22:31:23 +07:00
Mohamed Boudra
54282409bb refactor(sidebar): restore workspace name font weight to 400 2026-03-22 21:36:15 +07:00
Mohamed Boudra
f5b66f4745 test(agent-manager): add e2e tests for mode-switch update propagation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:12:53 +00:00
Mohamed Boudra
907074a454 fix(agent-manager): add missing touchUpdatedAt before emitState calls
Several emitState() calls were missing a preceding touchUpdatedAt(),
causing agent state updates to be silently dropped by the bootstrap
dedup logic in session.ts (which drops updates where
updateUpdatedAt <= snapshotUpdatedAt).

This caused two user-visible bugs:
- Permission mode switches not reflected in UI until next message
- Agent stuck showing "running" when it had transitioned to idle

Added touchUpdatedAt() to: setAgentMode, setAgentModel,
setAgentThinkingOption, setLabels, streamAgent.finalize,
replacement error fallback, and cancelAgentRun permission clear.
2026-03-22 21:08:36 +07:00
Mohamed Boudra
1f764d418c refactor(sidebar): mute diff stats and PR badge colors, add state label
- Reduce diff stat number brightness with muted green/red
- PR badge uses foregroundMuted by default, foreground on hover
- Show PR state label (Open/Merged/Closed) next to number
- Show external link icon on hover
2026-03-22 21:07:43 +07:00
Mohamed Boudra
e2248015aa refactor(sidebar): improve dropdown animations and spacing layout 2026-03-22 20:12:01 +07:00
Mohamed Boudra
1e5b809b70 refactor(sidebar): use status dot overlay on icons instead of replacing them
Sidebar workspace and project rows now keep their icon (Monitor/FolderGit2
for workspaces, project icon/initial for projects) and overlay a small
colored status dot on the bottom-right, matching the tab icon pattern.
Loading and syncing states still replace the icon entirely.
2026-03-22 19:45:26 +07:00
Mohamed Boudra
d79ea8b15c fix(sidebar): fix PR hint for fork PRs, remove badge styling, add hover effect
- Replace REST API PR lookup with `gh pr view` which handles fork PRs
- Fix state comparison to use lowercase (matching server output)
- Remove pill/badge styling, show plain icon + #number
- Add brighter hover colors and underline for clickability hint
2026-03-22 19:07:01 +07:00
Mohamed Boudra
c60ea67a9b Merge branch 'feat/sidebar-pr-hint' 2026-03-22 18:52:49 +07:00
Mohamed Boudra
4819011c97 feat(app): show keyboard shortcut for archive-worktree action 2026-03-22 18:52:20 +07:00
Mohamed Boudra
9be754cdbf fix(desktop): filter --no-sandbox from CLI arg parsing on Linux
The Linux wrapper injects --no-sandbox for Electron sandboxing, but
the CLI parser was treating it as an unknown Paseo option. Filter it
out alongside the existing macOS -psn_ prefix.
2026-03-22 18:52:01 +07:00
Mohamed Boudra
35ed62ad0f feat(sidebar): add PR status hint badge to workspace rows 2026-03-22 18:51:40 +07:00
Mohamed Boudra
d705d68f2f fix(desktop): simplify expo module build to plain tsc
Replace the cross-platform wrapper script with a direct tsc call.
expo-module build is just tsc + conditional --watch; tsc alone is
one-shot and cross-platform without needing env var hacks.
2026-03-22 18:19:50 +07:00
Mohamed Boudra
43a1f46047 refactor(shortcuts): rename section to "Shortcuts" and split into groups
Replace the flat "global" section with navigation, tabs-panes, projects,
and panels groups. Rename "Keyboard Shortcuts" to "Shortcuts" in settings
nav, section titles, and dialog.
2026-03-22 18:19:38 +07:00
Mohamed Boudra
1911946ed1 fix(desktop): make expo module build cross-platform 2026-03-22 18:02:26 +07:00
Mohamed Boudra
1572c95b4d Merge branch 'feat/chord-keyboard-shortcuts' 2026-03-22 17:44:51 +07:00
Mohamed Boudra
cc80d05e49 refactor(shortcuts): simplify chord display and clean up capture UI 2026-03-22 17:44:49 +07:00
Mohamed Boudra
83b681cebb fix(desktop): harden packaged launcher and desktop build 2026-03-22 17:38:10 +07:00
Mohamed Boudra
57f8c7615c Add chord keyboard shortcuts and staged capture UI 2026-03-22 16:28:09 +07:00
Mohamed Boudra
d9bcf2a439 dev(server): add heap snapshot and inspect flags to dev runner 2026-03-22 16:12:36 +07:00
Mohamed Boudra
051868c74c fix(app): remove terminal padding and fetch catch-up timeline on mobile resume 2026-03-22 15:45:47 +07:00
Mohamed Boudra
ffee4a472a Add keyboard shortcuts for worktree actions and dynamic shortcut display
Replace all hardcoded shortcut key rendering with useShortcutKeys() so
rebound shortcuts always display correctly. Add Cmd+O (new worktree),
Cmd+Shift+Backspace (archive worktree) with proper active-project
scoping. Show shortcut hints in tooltips and dropdown menus. Fix tooltip
alignment to center, bump dropdown menu surface level, simplify
TerminalPane to single-terminal, and add special key display symbols.
2026-03-22 15:43:45 +07:00
Mohamed Boudra
00a2eec5f6 Fix Linux AppImage sandbox startup 2026-03-22 01:16:38 +07:00
Mohamed Boudra
859d234543 Add no-sandbox switch for Linux alongside chrome-sandbox removal
Both are needed: afterPack removes chrome-sandbox to prevent SUID fatal,
appendSwitch handles Ubuntu 24.04+ AppArmor user namespace restrictions.
2026-03-22 01:12:01 +07:00
Mohamed Boudra
207bdd1fae desktop: remove chrome-sandbox from linux builds 2026-03-22 01:10:05 +07:00
Mohamed Boudra
d102a8fd64 fix Linux AppImage sandbox 2026-03-22 00:47:40 +07:00
Mohamed Boudra
fcbdb09796 Add resizable left sidebar on desktop
Mirror the right sidebar's resize pattern: Gesture.Pan() drag handle
on the right edge, animated width, persisted to panel store.
2026-03-21 23:44:40 +07:00
Mohamed Boudra
59f3f4737a Split diff horizontal scroll into platform-specific components
Use Metro .web.tsx resolution to separate native gesture arbitration
(RNGH ScrollView, waitFor, closeGestureRef) from web (plain RN ScrollView),
fixing text selection drag on desktop web.
2026-03-21 23:32:38 +07:00
Mohamed Boudra
c80c3e0256 Extract line number gutter width calculation and improve text selection 2026-03-21 23:15:52 +07:00
Mohamed Boudra
ad1cb4c200 Add keyboard shortcut rebinding with string-based format and sectioned settings UI
- Parse/format shortcut strings ("Cmd+Shift+O") bidirectionally via shortcut-string.ts
- Convert all 60+ bindings from structured KeyCombo objects to declarative strings
- Add keyboard shortcut overrides storage (AsyncStorage + React Query)
- Wire overrides into the keyboard shortcut resolution pipeline
- Add sectioned settings layout with sidebar on desktop, single scroll on mobile
- Add keyboard shortcuts settings section with rebind/reset UI and key capture
- Create useShortcutKeys hook so tooltip shortcut displays update on rebind
- Replace hardcoded shortcut displays in agent-input-area, left-sidebar, command center
2026-03-21 23:14:59 +07:00
Mohamed Boudra
68ab7b81a1 Remove cleanup-assets job from desktop release workflow
Assets should be overwritten by publish jobs, not deleted upfront.
The cleanup job was wiping macOS/Linux assets on platform-specific retries.
2026-03-21 22:34:56 +07:00
Mohamed Boudra
f1cf4ebe60 Fix Windows desktop build: split workspace deps from metro-patched export
The NODE_OPTIONS --require patch for metro config was being applied
during build:workspace-deps, causing expo-module-build (a bash script)
to be loaded through Node's JS loader on Windows.
2026-03-21 22:31:59 +07:00
Mohamed Boudra
0a452fad04 Sort Claude model catalog from most to least powerful
Reorder: Opus 4.6 → Sonnet 4.6 → Sonnet 4.5 → Haiku 4.5
2026-03-21 22:26:30 +07:00
Mohamed Boudra
eb618638d2 Use useMutation for worktree creation with loading spinner on mobile
Move worktree creation into a useMutation in ProjectHeaderRow so each
row owns its own pending state. Show ActivityIndicator on the + button
while the request is in flight, and always show the + button on mobile
breakpoints where hover isn't available.
2026-03-21 22:26:17 +07:00
Mohamed Boudra
7b8a2fbcef Fix deleted diff syntax highlighting 2026-03-21 22:25:53 +07:00
Mohamed Boudra
928169a4f2 Add highlight package to build:daemon pipeline and add build:highlight script 2026-03-21 21:01:14 +07:00
Mohamed Boudra
0af666e3ac Dismiss keyboard before opening mobile tab switcher sheet 2026-03-21 20:47:42 +07:00
Mohamed Boudra
d41808efe1 fix(app): clear composer immediately on submit 2026-03-21 20:47:33 +07:00
Mohamed Boudra
aec28c8b67 Fix mobile diff mode trigger clipping 2026-03-21 20:47:02 +07:00
Mohamed Boudra
3b5daa1451 Fix agent preferences sheet restoring tab switcher on close
Use stackBehavior="replace" on the preferences BottomSheetModal so it
dismisses the tab switcher combobox instead of minimizing it, preventing
the provider from auto-restoring the tab switcher when preferences closes.
2026-03-21 20:46:44 +07:00
Mohamed Boudra
f6579dbeb4 Switch to double quotes and reformat codebase with Biome 2026-03-21 20:22:15 +07:00
Mohamed Boudra
ab40786882 Remove freezeOnBlur from workspace and agent screens 2026-03-21 20:21:57 +07:00
Mohamed Boudra
5f065c2685 Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview
- Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps)
- Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component
- Import shared highlight colors in git-diff-pane.tsx instead of local definitions
- Delete duplicated syntax-highlighter.ts from both app and server
- Fix TSX dialect to "ts jsx" (correct per Lezer docs)
2026-03-21 19:56:49 +07:00
Mohamed Boudra
6b50ace2bd Refactor agent input draft persistence 2026-03-21 19:56:23 +07:00
Mohamed Boudra
c46c03158e Simplify Claude agent session flow 2026-03-21 19:47:56 +07:00
Mohamed Boudra
7395fc6fd1 fix: interrupt scaffold drain and desktop CLI bin layout 2026-03-21 18:37:51 +07:00
Mohamed Boudra
c35d649f6d refactor(app): rename agents route to sessions with improved list layout 2026-03-21 16:20:17 +07:00
Mohamed Boudra
60e833245d fix(desktop): add cleanup-assets job to delete stale release assets before rebuild 2026-03-21 15:56:01 +07:00
Mohamed Boudra
f424503234 fix(app): skip agent list refresh when screen is unfocused 2026-03-21 15:47:43 +07:00
Mohamed Boudra
1d42542514 refactor: extract isInteractiveDesktopDragTarget and add debug logging 2026-03-21 15:44:49 +07:00
Mohamed Boudra
d1314a4d5d Merge branch 'feat/unit-1-workspace-tab-store-navigation'
# Conflicts:
#	packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
#	packages/app/src/screens/workspace/workspace-agent-visibility.test.ts
#	packages/app/src/screens/workspace/workspace-agent-visibility.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
2026-03-21 15:27:35 +07:00
Mohamed Boudra
48c3308c31 refactor: move workspace tab navigation logic to prepareWorkspaceTab 2026-03-21 15:21:02 +07:00
Mohamed Boudra
ba149330b1 fix(desktop): fix release workflow publish target, signing, and Windows build
- Change electron-builder publish owner from anthropics to getpaseo
- Remove CSC_NAME (auto-discovered from cert, secret had rejected prefix)
- Remove CSC_IDENTITY_AUTO_DISCOVERY=false from build script (breaks Windows cmd.exe)
2026-03-21 15:02:40 +07:00
Mohamed Boudra
6731879931 fix(desktop): guard manual window drag coordinates 2026-03-21 14:58:57 +07:00
Mohamed Boudra
b15159caeb chore(release): cut 0.1.32 2026-03-21 14:47:53 +07:00
869 changed files with 47468 additions and 49079 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "android-v*"
workflow_dispatch:
inputs:
tag:
@@ -16,7 +17,7 @@ concurrency:
cancel-in-progress: false
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
@@ -31,6 +32,18 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:

View File

@@ -132,9 +132,9 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -238,6 +238,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"
@@ -313,12 +314,16 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
@@ -342,6 +347,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"

View File

@@ -1,21 +0,0 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo

View File

@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}

View File

@@ -1,5 +1,36 @@
# Changelog
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
- Expanded diff syntax highlighting to cover more languages.
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
- Desktop key repeat now works correctly on macOS.
- Desktop notifications on macOS are more reliable.
- Daemon startup no longer blocks on model downloads.
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
- Fixed desktop window drag coordinates being applied when they shouldn't be.
## 0.1.30 - 2026-03-19
### Added

32
biome.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}

74
docs/PRODUCT.md Normal file
View File

@@ -0,0 +1,74 @@
# Product
What Paseo is, who it's for, and where it's going.
## What is Paseo
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
## Core philosophy
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## How it works
### Projects and workspaces
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
### The daemon
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
## Target user
Anyone who builds software:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
## What compounds over time
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## Strategic bets
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model

View File

@@ -37,18 +37,24 @@ npm run release:finalize # Publish npm, promote draft to published
**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:
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
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
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
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.
```bash
# Desktop (all platforms)
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
# Desktop (single platform)
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
## Notes

927
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{
"name": "paseo",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/server",
"packages/app",
"packages/relay",
@@ -13,17 +14,18 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"build": "npm run build --workspaces --if-present",
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format": "biome format --write .",
"format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -56,7 +58,8 @@
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"prettier": "^3.5.3",
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
@@ -81,6 +84,7 @@
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
}
}

View File

@@ -65,8 +65,7 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription:
"This app needs access to the microphone for voice commands.",
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
@@ -92,9 +91,7 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile
? { googleServicesFile: variant.googleServicesFile }
: {}),
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
output: "single",

View File

@@ -1,15 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
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({
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
},
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === '6767') {
if (daemonPort === "6767") {
throw new Error(
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
@@ -42,17 +39,17 @@ test.beforeEach(async ({ page }) => {
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on('console', (message) => {
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
}
}
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
});
@@ -105,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
return;
}
await testInfo.attach('browser-console', {
body: entries.join('\n'),
contentType: 'text/plain',
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
});

View File

@@ -1,11 +1,11 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
type WaitForServerOptions = {
host?: string;
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to acquire port')));
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
}
},
dump() {
return lines.join('\n');
return lines.join("\n");
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return '';
return "";
}
const output = getRecentOutput().trim();
if (!output) {
return '';
return "";
}
return `\nRecent output:\n${output}`;
}
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const {
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
);
}
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on('error', reject);
socket.on("error", reject);
});
return;
} catch (error) {
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
const reason =
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
);
}
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill('SIGTERM');
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
child.kill("SIGKILL");
}
resolve();
}, 5000);
child.once('exit', () => {
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return 'empty response body';
return "empty response body";
}
if (trimmed.length <= 240) {
return trimmed;
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
try {
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
method: 'GET',
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
},
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
return false;
}
@@ -196,42 +192,42 @@ type OfferPayload = {
};
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
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', {
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot,
stdio: 'inherit',
stdio: "inherit",
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const marker = "#offer=";
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const json = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(json) as Partial<OfferPayload>;
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
if (!offer.serverId) throw new Error('offer.serverId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
return offer as OfferPayload;
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test');
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
@@ -239,13 +235,17 @@ export default async function globalSetup() {
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
@@ -256,24 +256,30 @@ export default async function globalSetup() {
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? 'openai' : 'local';
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
".paseo",
"models",
"local-speech",
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
);
}
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
try {
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
@@ -285,18 +291,21 @@ export default async function globalSetup() {
let relayReadyForSelectedPort = false;
relayProcess = spawn(
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
},
);
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -311,8 +320,11 @@ export default async function globalSetup() {
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -330,7 +342,7 @@ export default async function globalSetup() {
try {
await waitForServer(relayPort, {
label: 'Relay dev server',
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
@@ -353,15 +365,15 @@ export default async function globalSetup() {
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
@@ -380,40 +392,46 @@ export default async function globalSetup() {
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..');
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: 'none', // Don't auto-open browser
BROWSER: "none", // Don't auto-open browser
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
@@ -421,33 +439,33 @@ export default async function globalSetup() {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: '1',
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai',
PASEO_VOICE_TTS_PROVIDER: 'openai',
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: 'development',
NODE_ENV: "development",
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@@ -456,13 +474,13 @@ export default async function globalSetup() {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
@@ -476,8 +494,11 @@ export default async function globalSetup() {
}
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
@@ -487,12 +508,12 @@ export default async function globalSetup() {
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: 'Paseo daemon',
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: 'Metro web server',
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
@@ -503,11 +524,11 @@ export default async function globalSetup() {
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
),
]);
if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs');
throw new Error("pairing_offer was not parsed from daemon logs");
}
const offer = offerPayload as OfferPayload;
@@ -516,11 +537,13 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
return async () => {
await cleanup();
console.log('[e2e] Test daemon stopped');
console.log("[e2e] Test daemon stopped");
};
} catch (error) {
await cleanup();

View File

@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "../../src/utils/host-routes";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -37,10 +34,7 @@ export type DaemonClientInstance = {
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number
): Promise<{ status: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
@@ -89,14 +83,16 @@ export function createReplyTurn(label: string): {
};
}
async function loadDaemonClientConstructor(): Promise<new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance> {
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")
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
@@ -141,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
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}.`
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
@@ -153,7 +149,7 @@ export async function seedBottomAnchorAgent(input: {
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}.`
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
@@ -162,7 +158,7 @@ export async function seedBottomAnchorAgent(input: {
id: created.id,
title,
expectedTailText,
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
@@ -186,18 +182,13 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
const scrollElement =
candidates.sort(
(left, right) =>
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
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)
);
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
return {
offsetY,
@@ -221,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
deltaY: -step,
bubbles: true,
cancelable: true,
})
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
@@ -270,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number
previousContentHeight: number,
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
@@ -283,8 +274,8 @@ export async function waitForContentGrowth(
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;
});
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -1,20 +1,19 @@
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === '6767') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
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;
}
}, { expectedEndpoint, expectedServerId });
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
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;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences }
{ daemon, preferences },
);
await page.reload();
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${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 === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
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)}).`,
);
}
if (!snapshot.prefsRaw) {
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
}
export const gotoAppShell = async (page: Page) => {
await page.goto('/');
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
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();
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 expect
.poll(
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 }
{ timeout: 10000 },
)
.toBe(true);
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
@@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) {
return;
}
await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(200);
}
if (await bottomSheetBackdrop.isVisible()) {
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return;
}
const attachToggle = page.getByTestId('worktree-attach-toggle');
const attachToggle = page.getByTestId("worktree-attach-toggle");
if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true });
await page.waitForTimeout(200);
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.first();
if (await plainOption.isVisible().catch(() => false)) {
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter');
await activeInput.press("Enter");
}
} else {
// Legacy path picker fallback.
await activeInput.press('Enter');
await activeInput.press("Enter");
}
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {
if (trimmedDirectory.startsWith("/var/")) {
directoryCandidates.add(`/private${trimmedDirectory}`);
}
if (trimmedDirectory.startsWith('/private/var/')) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
await expect.poll(async () => {
const text = await workingDirectorySelect.innerText().catch(() => '');
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
await expect
.poll(
async () => {
const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
},
{ timeout: 30000 },
)
.toBe(true);
};
export const ensureHostSelected = async (page: Page) => {
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
if (await input.isEditable()) {
return;
}
const selectHostLabel = page.getByText('Select host', { exact: true });
const selectHostLabel = page.getByText("Select host", { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText('localhost', { exact: true }).first();
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
};
export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message);
await input.press('Enter');
await input.press("Enter");
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
@@ -385,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
if (!/codex/i.test(providerText)) {
return;
}
}
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
await thinkingTrigger.click();
const menu = page.getByTestId('agent-thinking-menu').first();
const menu = page.getByTestId("agent-thinking-menu").first();
if (!(await menu.isVisible().catch(() => false))) {
return;
}
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
const preferredLabels = ["low", "minimal", "off", "medium"];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
const options = menu.getByRole('button');
const options = menu.getByRole("button");
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
if (!label) {
continue;
}
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
return;
}
@@ -462,15 +495,17 @@ export interface AgentConfig {
export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim();
if (!normalizedProvider) {
throw new Error('Provider must be a non-empty string.');
throw new Error("Provider must be a non-empty string.");
}
const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const dialog = page.getByRole('dialog').last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click();
};
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim();
if (!normalizedModel) {
throw new Error('Model must be a non-empty string.');
throw new Error("Model must be a non-empty string.");
}
const modelTrigger = page
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
.locator(
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
)
.first();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText('MODEL', { exact: true }).first();
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i });
const searchInput = page.getByRole("textbox", { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models
await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog');
const dialog = page.getByRole("dialog");
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first();
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press('Enter');
await searchInput.press("Enter");
}
// Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
}
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
};
export const selectMode = async (page: Page, mode: string) => {
const modeTrigger = page
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
.locator(
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
)
.first();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText('MODE', { exact: true }).first();
const modeLabel = page.getByText("MODE", { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i });
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to filter modes
await searchInput.fill(mode);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
const dialog = page.getByRole("dialog");
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click({ force: true });
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, 'directory' | 'prompt'>
config: Pick<AgentConfig, "directory" | "prompt">,
) => {
await gotoHome(page);
await ensureHostSelected(page);
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId('permission-request-question').first();
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId('permission-request-accept').first();
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId('permission-request-deny').first();
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
@@ -649,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
// If no specific message, just wait for the button to disappear
});
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });

View File

@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {}
options: { durationMs?: number; intervalMs?: number } = {},
): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50;

View File

@@ -1,38 +1,38 @@
import { expect, type Page } from '@playwright/test';
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
import { gotoHome } from './app';
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = normalized.split('/').filter(Boolean);
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, '');
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith('/var/')) {
if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith('/private/var/')) {
candidates.add(trimmed.replace(/^\/private/, ''));
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
);
return page.locator(ids.join(',')).first();
return page.locator(ids.join(",")).first();
}
export async function switchWorkspaceViaSidebar(input: {
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string }
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId('workspace-header-title');
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker);
await input.press('Enter');
await input.press("Enter");
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}

View File

@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
path: string;
@@ -9,29 +9,29 @@ type TempRepo = {
};
export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, 'remote.git');
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
}
return {

View File

@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
ignores: ["dist/*"],
},
]);

View File

@@ -1,12 +1,12 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
@@ -22,7 +22,7 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web": "npm run build:workspace-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
@@ -31,25 +31,11 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -58,8 +44,13 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
@@ -88,7 +79,6 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",

View File

@@ -1,12 +1,13 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
// This allows multiple test runs in parallel across different worktrees
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
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',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -16,17 +17,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs

View File

@@ -1,8 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ??
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",

View File

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

View File

@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
}`,
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
@@ -108,5 +108,5 @@ rl.question(
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
},
);

View File

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

View File

@@ -99,10 +99,10 @@ function PushNotificationRouter() {
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? ((payload as { data: Record<string, unknown> }).data)
? (payload as { data: Record<string, unknown> }).data
: undefined;
router.push(buildNotificationRoute(data) as any);
}
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
@@ -124,18 +124,12 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -163,8 +157,7 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(data) as any);
};
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
@@ -188,11 +181,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
}
return (
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
{null}
</SessionProvider>
);
@@ -275,9 +264,11 @@ function AppContainer({
const daemons = useHosts();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
@@ -286,20 +277,20 @@ function AppContainer({
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
});
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0]
[theme.colors.surface0],
);
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>
{children}
</View>
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>{children}</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -314,11 +305,7 @@ function AppContainer({
return content;
}
return (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
{content}
</MobileGestureWrapper>
);
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
}
function MobileGestureWrapper({
@@ -331,14 +318,8 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
@@ -379,7 +360,7 @@ function MobileGestureWrapper({
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
@@ -406,7 +387,7 @@ function MobileGestureWrapper({
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
]
],
);
return (
@@ -472,7 +453,9 @@ function OfferLinkListener({
});
};
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
void Linking.getInitialURL()
.then(handleUrl)
.catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
@@ -609,9 +592,7 @@ function MissingDaemonView() {
export default function RootLayout() {
return (
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
@@ -636,16 +617,13 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
options={{ freezeOnBlur: true }}
/>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false, freezeOnBlur: true }}
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />

View File

@@ -2,10 +2,8 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
const router = useRouter();
@@ -39,7 +37,11 @@ export default function HostAgentReadyRoute() {
if (normalizedCwd) {
redirectedRef.current = true;
router.replace(
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -78,7 +80,13 @@ export default function HostAgentReadyRoute() {
const cwd = result?.agent?.cwd?.trim();
redirectedRef.current = true;
if (cwd) {
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any,
);
return;
}
router.replace(buildHostRootRoute(serverId) as any);

View File

@@ -5,9 +5,9 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
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;
@@ -59,11 +57,11 @@ export default function HostIndexRoute() {
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
buildHostWorkspaceAgentRoute(
prepareWorkspaceTab({
serverId,
primaryAgent.cwd.trim(),
primaryAgent.id
) as any
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any,
);
return;
}
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
}

View File

@@ -1,9 +1,9 @@
import { useLocalSearchParams } from "expo-router";
import { AgentsScreen } from "@/screens/agents-screen";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostAgentsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <AgentsScreen serverId={serverId} />;
return <SessionsScreen serverId={serverId} />;
}

View File

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

View File

@@ -1,82 +1,82 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from '@/app-support/index-startup'
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
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
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
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
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
}
)
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer)
}
}, [])
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return
return;
}
if (pathname !== '/' && pathname !== '') {
return
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
@@ -88,10 +88,10 @@ export default function Index() {
daemonCount: daemons.length,
})
) {
return
return;
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if (
shouldWaitOnStartupRace({
@@ -102,12 +102,12 @@ export default function Index() {
pathname,
})
) {
return <StartupSplashScreen />
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />
return <WelcomeScreen />;
}
return null
return null;
}

View File

@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost]
)
[pendingNameHost],
),
);
const returnToSource = useCallback(
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId]
[router, source, sourceServerId, targetServerId],
);
const closeToSource = useCallback(() => {
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return;
}
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing

View File

@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}

View File

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

View File

@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
})
}),
);
},
};

View File

@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
});
return null;
}
})
}),
);
const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
(entry): entry is { data: string; mimeType: string } => entry !== null,
);
return valid.length > 0 ? valid : undefined;
}
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
@@ -113,7 +111,7 @@ export async function deleteAttachments(
error,
});
}
})
}),
);
}

View File

@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
return createNativeFileAttachmentStore();
}

View File

@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
return url;
}

View File

@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source;
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
const blob =
source.blob.type === mimeType
? source.blob
: source.blob.slice(0, source.blob.size, mimeType);
return { blob, mimeType };
}
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id)
store.get(id),
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
);
} finally {
db.close();
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
};
cursorRequest.onsuccess = () => {

View File

@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
@@ -147,20 +147,21 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
@@ -174,15 +175,21 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);

View File

@@ -1,15 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -109,6 +101,7 @@ export interface AdaptiveModalSheetProps {
onClose: () => void;
children: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
}
@@ -118,11 +111,11 @@ export function AdaptiveModalSheet({
onClose,
children,
snapPoints,
stackBehavior,
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -148,19 +141,14 @@ export function AdaptiveModalSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
if (isMobile) {
@@ -173,6 +161,7 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -180,11 +169,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -209,11 +194,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -254,13 +235,12 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
}
},
);

View File

@@ -60,8 +60,17 @@ export function AddHostMethodModal({
}, [onPasteLink]);
return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<AdaptiveModalSheet
title="Add connection"
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
</Pressable>
) : null}
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>

View File

@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
return unique.join(" — ");
}
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
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 error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
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 {
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
} finally {
setIsSaving(false);
}
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>

View File

@@ -1,76 +1,77 @@
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case 'read':
return '📖';
case 'edit':
return '✏️';
case 'delete':
return '🗑️';
case 'move':
return '📦';
case 'search':
return '🔍';
case 'execute':
return '▶️';
case 'think':
return '💭';
case 'fetch':
return '🌐';
case 'switch_mode':
return '🔄';
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return '🔧';
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case 'pending':
return '#9ca3af';
case 'in_progress':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return '#6b7280';
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === 'thought';
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return (
<View style={stylesheet.toolCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(item.startTimestamp)}
</Text>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.input, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.output, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>
No details available
</Text>
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== 'plan') {
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>
📋 Tasks ({update.entries.length})
</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text
style={[
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return (
<View style={stylesheet.unknownCard}>
<Pressable
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>
{JSON.stringify(update, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ('kind' in item && item.kind === 'grouped_text') {
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ('kind' in item && item.kind === 'merged_tool_call') {
if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />;
}
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update;
// Tasks
if (update.kind === 'plan') {
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === 'available_commands_update') {
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
}
// Current mode update
if (update.kind === 'current_mode_update') {
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Mode changed to: {update.currentModeId}
</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: 'italic',
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
toolHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: 'italic',
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
planEntry: {
flexDirection: 'row',
alignItems: 'flex-start',
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: 'flex-start',
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,

View File

@@ -1,13 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -17,7 +10,18 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } 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,
@@ -107,19 +111,14 @@ export function DropdownField({
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
</View>
);
}
@@ -176,7 +175,7 @@ export function SelectField({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
@@ -184,9 +183,7 @@ export function SelectField({
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<View style={styles.selectFieldContainer}>
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export function DropdownSheet({
@@ -267,19 +259,14 @@ export function DropdownSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
return (
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<Pressable
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
>
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}>
{showLabel ? (
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
@@ -540,7 +523,7 @@ export function AgentConfigRow({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -554,9 +537,7 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto" },
];
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
@@ -572,7 +553,7 @@ export function AgentConfigRow({
id: option.id,
label: option.label,
})),
[thinkingOptions]
[thinkingOptions],
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
@@ -609,7 +590,9 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
testID="draft-model-select"
/>
@@ -638,7 +621,9 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
/>
</View>
@@ -664,7 +649,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
(definition) => definition.id === selectedProvider,
);
const options = useMemo(
@@ -673,7 +658,7 @@ export function AssistantDropdown({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
"Default")
: "Automatic";
const options = useMemo(
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
label: mode.label,
description: mode.description,
})),
[modeOptions]
[modeOptions],
);
const handleOpen = useCallback(() => {
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
hasOptions ? undefined : "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
@@ -795,7 +778,7 @@ export function ModelDropdown({
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
@@ -834,7 +817,7 @@ export function ModelDropdown({
onSelect(id);
}
},
[onClear, onSelect]
[onClear, onSelect],
);
return (
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths]
[suggestedPaths],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -957,9 +940,7 @@ export function ToggleRow({
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
{description ? <Text style={styles.helperText}>{description}</Text> : null}
</View>
</Pressable>
);
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}>
<Pressable
testID="worktree-create-toggle"
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
<Pressable
testID="worktree-attach-toggle"
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
<Pressable
onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
</View>
);
}

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard";
describe("shouldSkipDraftPersist", () => {
it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 2,
hydratedGeneration: 1,
isCurrentGeneration: true,
})
).toBe(true);
});
it("allows persist after hydrate completes for current generation", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 3,
hydratedGeneration: 3,
isCurrentGeneration: true,
})
).toBe(false);
});
it("blocks persist for stale generations", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 4,
hydratedGeneration: 4,
isCurrentGeneration: false,
})
).toBe(true);
});
it("does not block controlled draft persistence", () => {
expect(
shouldSkipDraftPersist({
isControlled: true,
currentGeneration: 0,
hydratedGeneration: 0,
isCurrentGeneration: true,
})
).toBe(false);
});
});

View File

@@ -1,20 +0,0 @@
export function shouldSkipDraftPersist(input: {
isControlled: boolean;
currentGeneration: number;
hydratedGeneration: number;
isCurrentGeneration: boolean;
}): boolean {
if (input.isControlled) {
return false;
}
if (input.currentGeneration <= 0) {
return true;
}
if (!input.isCurrentGeneration) {
return true;
}
return input.hydratedGeneration !== input.currentGeneration;
}

View File

@@ -1,31 +1,31 @@
import { describe, expect, it } from 'vitest'
import { resolveStatusControlMode } from './agent-input-area.status-controls'
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
describe('resolveStatusControlMode', () => {
it('uses ready mode when no controlled status controls are provided', () => {
expect(resolveStatusControlMode(undefined)).toBe('ready')
})
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe("ready");
});
it('uses draft mode when controlled status controls are provided', () => {
it("uses draft mode when controlled status controls are provided", () => {
expect(
resolveStatusControlMode({
providerDefinitions: [],
selectedProvider: 'codex',
selectedProvider: "codex",
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: '',
selectedMode: "",
onSelectMode: () => undefined,
models: [],
selectedModel: '',
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
})
).toBe('draft')
})
})
}),
).toBe("draft");
});
});

View File

@@ -1,5 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
return statusControls ? "draft" : "ready";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import { submitAgentInput } from "./agent-input-submit";
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
resolve,
reject,
};
}
describe("submitAgentInput", () => {
it("clears the composer before an in-flight submit resolves", async () => {
const deferred = createDeferredPromise<void>();
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " hello world ",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "hello world",
imageAttachments: undefined,
});
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("queues while the agent is running and clears the composer immediately", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn();
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " queued message ",
imageAttachments: [{ id: "img-1" }],
isAgentRunning: true,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
}),
).resolves.toBe("queued");
expect(queueMessage).toHaveBeenCalledWith({
message: "queued message",
imageAttachments: [{ id: "img-1" }],
});
expect(submitMessage).not.toHaveBeenCalled();
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setSendError).not.toHaveBeenCalled();
expect(setIsProcessing).not.toHaveBeenCalled();
expect(clearDraft).not.toHaveBeenCalled();
});
it("restores the composer when submit fails", async () => {
const submitError = new Error("No host selected");
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
throw submitError;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const onSubmitError = vi.fn();
const imageAttachments = [{ id: "img-1" }];
await expect(
submitAgentInput({
message: " hello world ",
imageAttachments,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
onSubmitError,
}),
).resolves.toBe("failed");
expect(onSubmitError).toHaveBeenCalledWith(submitError);
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
expect(setSendError).toHaveBeenNthCalledWith(1, null);
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(clearDraft).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,58 @@
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TImage> {
message: string;
imageAttachments?: TImage[];
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
setUserInput: (text: string) => void;
setSelectedImages: (images: TImage[]) => void;
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
}
export async function submitAgentInput<TImage>(
input: AgentInputSubmitActionInput<TImage>,
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const imageAttachments = input.imageAttachments;
if (!trimmedMessage && !imageAttachments?.length) {
return "noop";
}
if (!input.canSubmit) {
return "noop";
}
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, imageAttachments });
input.setUserInput("");
input.setSelectedImages([]);
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setSelectedImages([]);
input.setSendError(null);
input.setIsProcessing(true);
try {
await input.submitMessage({ message: trimmedMessage, imageAttachments });
input.clearDraft("sent");
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setSelectedImages(imageAttachments ?? []);
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";
}
}

View File

@@ -6,106 +6,107 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router, type Href } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
agents: AggregatedAgent[]
showCheckoutInfo?: boolean
isRefreshing?: boolean
onRefresh?: () => void
selectedAgentId?: string
onAgentSelect?: () => void
listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
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[]
}
type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: 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()
)
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";
}
function formatStatusLabel(status: AggregatedAgent['status']): string {
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'
case "initializing":
return "Starting";
case "idle":
return "Idle";
case "running":
return "Running";
case "error":
return "Error";
case "closed":
return "Closed";
default:
return status
return status;
}
}
function SessionBadge({
label,
tone = 'neutral',
icon,
tone = "neutral",
}: {
label: string
tone?: 'neutral' | 'warning' | 'danger'
label: string;
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
return (
<View
style={[
styles.badge,
tone === 'warning' && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
{icon}
<Text
style={[
styles.badgeText,
tone === 'warning' && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
)
);
}
function SessionRow({
@@ -116,18 +117,19 @@ function SessionRow({
onPress,
onLongPress,
}: {
agent: AggregatedAgent
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void
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)
const { theme } = useUnistyles();
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
@@ -141,18 +143,20 @@ function SessionRow({
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<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'}
{agent.title || "New session"}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
icon={<Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
@@ -195,49 +199,7 @@ function SessionRow({
</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({
@@ -249,96 +211,113 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const { theme } = useUnistyles();
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 serverId = agent.serverId;
const agentId = agent.id;
onAgentSelect?.()
onAgentSelect?.();
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
router.navigate(route)
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route as any);
},
[isActionSheetVisible, onAgentSelect]
)
[isActionSheetVisible, 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])
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 flatItems = useMemo((): FlatListItem[] => {
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: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label)
const data = buckets.get(label);
if (!data || data.length === 0) {
continue
continue;
}
result.push({ type: "header", key: `header:${label}`, title: label });
for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
result.push({ key: `date:${label}`, title: label, data })
}
return result
}, [agents])
return result;
}, [agents]);
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 renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
</View>
);
}
return (
<SessionRow
agent={item.agent}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
);
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
return (
<>
<FlatList
data={sections}
data={flatItems}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderSection}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListFooterComponent={listFooterComponent}
@@ -370,7 +349,7 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -400,7 +379,7 @@ export function AgentList({
</View>
</Modal>
</>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -413,18 +392,16 @@ const styles = StyleSheet.create((theme) => ({
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[2],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
gap: theme.spacing[1],
},
sectionBlock: {
marginTop: theme.spacing[2],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
marginTop: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
},
sectionTitle: {
@@ -432,26 +409,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
listCard: {
overflow: {
xs: 'hidden' as const,
md: 'visible' as const,
},
borderRadius: {
xs: theme.borderRadius.lg,
md: 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: {
@@ -463,23 +423,20 @@ const styles = StyleSheet.create((theme) => ({
md: 0,
},
},
rowLeading: {
marginRight: theme.spacing[3],
},
rowContent: {
flex: 1,
minWidth: 0,
},
rowTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
rowMetaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
marginTop: 2,
},
@@ -498,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
sessionTitle: {
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: '500',
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.86,
},
@@ -506,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
sessionMetaText: {
maxWidth: '100%',
maxWidth: "100%",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
@@ -528,19 +485,22 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 72,
textAlign: 'right' as const,
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)',
backgroundColor: "rgba(245, 158, 11, 0.12)",
},
badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)',
backgroundColor: "rgba(239, 68, 68, 0.14)",
},
badgeText: {
fontSize: theme.fontSize.xs,
@@ -555,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
},
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,
@@ -585,18 +545,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,
@@ -617,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}))
}));

View File

@@ -1,72 +1,72 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from './agent-status-bar.utils'
} from "./agent-status-bar.utils";
describe('getStatusSelectorHint', () => {
it('explains what each editable status control does', () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
expect(getStatusSelectorHint('model')).toBe('Change model')
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
})
})
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
});
});
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {
expect(normalizeModelId('')).toBeNull()
expect(normalizeModelId(' default ')).toBeNull()
expect(normalizeModelId(undefined)).toBeNull()
})
describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => {
expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull();
});
it('returns trimmed model ids', () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
})
})
it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
});
});
describe('resolveAgentModelSelection', () => {
it('prefers runtime model over configured model', () => {
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [{ id: 'low', label: 'Low' }],
defaultThinkingOptionId: 'low',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
configuredModelId: 'b',
runtimeModelId: "a",
configuredModelId: "b",
explicitThinkingOptionId: null,
})
});
expect(selection.activeModelId).toBe('a')
expect(selection.displayModel).toBe('Model A')
expect(selection.selectedThinkingId).toBe('low')
})
expect(selection.activeModelId).toBe("a");
expect(selection.displayModel).toBe("Model A");
expect(selection.selectedThinkingId).toBe("low");
});
it('uses explicit thinking option when provided', () => {
it("uses explicit thinking option when provided", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: 'low',
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: 'high',
})
explicitThinkingOptionId: "high",
});
expect(selection.selectedThinkingId).toBe('high')
expect(selection.displayThinking).toBe('High')
})
})
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,54 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
case 'thinking':
return 'Thinking mode'
case 'model':
return 'Change model'
case 'mode':
return 'Change permission mode'
case "thinking":
return "Thinking mode";
case "model":
return "Change model";
case "mode":
return "Change permission mode";
}
}
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {
return null
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized
return normalized;
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null
runtimeModelId: string | null | undefined
configuredModelId: string | null | undefined
explicitThinkingOptionId: string | null | undefined
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel =
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return {
selectedModel,
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
thinkingOptions,
selectedThinkingId,
displayThinking,
}
};
}

View File

@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",

View File

@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "above",
})
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "below",
})
}),
).toBeUndefined();
});
});
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
});
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 300,
contentHeight: 1000,
})
}),
).toBe(0);
});
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
strategy,
viewportHeight: 320,
contentHeight: 1000,
})
}),
).toBe(680);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(0);
});
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(39);
});
});
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
__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;
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
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;
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
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;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
}
}
});

View File

@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
(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
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}

View File

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

View File

@@ -1,10 +1,4 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>

View File

@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
}
export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) {
export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles();
const [copied, setCopied] = useState(false);
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
</View>
{info.debugRecordingPath ? (
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
<Text
numberOfLines={2}
style={[styles.pathText, { color: theme.colors.foreground }]}
>
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
{stats}
</Text>
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);

View File

@@ -1,3 +1,12 @@
/**
* Compute the pixel width for a line-number gutter based on the highest
* line number that will be displayed. Minimum width accommodates 2 digits.
*/
export function lineNumberGutterWidth(maxLineNumber: number): number {
const digits = Math.max(2, String(maxLineNumber).length);
return digits * 8 + 12;
}
export function getCodeInsets(theme: any) {
const padding =
typeof theme.spacing?.[3] === "number"

View File

@@ -1,24 +1,24 @@
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'
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
const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string }
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
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
disabled?: boolean;
}
export function CombinedModelSelector({
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
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 { 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 })
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("");
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
},
[allProviderModels, selectedProvider],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setSearchQuery("");
},
[onSelect]
)
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider)
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])
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 (
<>
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
setView({ provider });
setSearchQuery("");
}}
/>
) : (
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
setView("groups");
setSearchQuery("");
}}
/>
)}
</Combobox>
</>
)
);
}
function GroupsView({
@@ -140,21 +143,21 @@ function GroupsView({
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles()
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)
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
@@ -193,10 +196,10 @@ function GroupsView({
</Pressable>
)}
</View>
)
);
})}
</View>
)
);
}
function DrillDownModelView({
@@ -210,27 +213,27 @@ function DrillDownModelView({
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
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 { 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()
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])
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
@@ -251,7 +254,7 @@ function DrillDownModelView({
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
@@ -270,18 +273,18 @@ function DrillDownModelView({
</View>
) : null}
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
drillDownCount: {
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
alignItems: "center",
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))
}));

View File

@@ -1,12 +1,4 @@
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, 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";
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
export function CommandCenter() {
const { theme } = useUnistyles();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
} = useCommandCenter();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
@@ -77,11 +61,13 @@ export function CommandCenter() {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
@@ -110,18 +96,16 @@ export function CommandCenter() {
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
style={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
@@ -131,10 +115,7 @@ export function CommandCenter() {
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
{ color: theme.colors.foreground },
]}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -164,11 +145,7 @@ export function CommandCenter() {
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
@@ -201,7 +178,7 @@ export function CommandCenter() {
</View>
</View>
{action.shortcutKeys ? (
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>

View File

@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View
style={[
styles.dot,
isConnected ? styles.dotConnected : styles.dotDisconnected,
]}
/>
<Text
style={[
styles.text,
isConnected ? styles.textConnected : styles.textDisconnected,
]}
>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? "Connected" : "Disconnected"}
</Text>
</View>

View File

@@ -24,9 +24,7 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
@@ -66,12 +64,7 @@ export function DictationControls({
return (
<View style={styles.activeContainer}>
<View style={styles.meterWrapper}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
/>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)}
@@ -152,12 +145,7 @@ export function DictationOverlay({
}
return (
<View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.accent },
]}
>
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -180,12 +168,7 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
</View>
@@ -205,26 +188,16 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
</View>
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<RefreshCcw
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : (
<>
@@ -232,10 +205,7 @@ export function DictationOverlay({
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
>
<Pencil
size={theme.iconSize.lg}
@@ -247,16 +217,9 @@ export function DictationOverlay({
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<ArrowUp
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
)}

View File

@@ -1,6 +1,14 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native";
import {
AlertTriangle,
CheckCircle2,
Info,
RefreshCcw,
RotateCcw,
WifiOff,
X,
} from "lucide-react-native";
export type DictationToastVariant = "info" | "success" | "warning" | "error";
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
})();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
const secondaryColor =
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return (
<View
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? (
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
{actionLabel && onAction ? (
<Pressable
style={[

View File

@@ -0,0 +1,85 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Not inside ExplorerSidebarAnimationProvider, which is fine
}
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
);
}

View File

@@ -0,0 +1,29 @@
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>
{children}
</ScrollView>
);
}

View File

@@ -71,7 +71,8 @@ export function DiffViewer({
key={segIdx}
style={[
line.type === "add" ? styles.addText : styles.removeText,
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight),
segment.changed &&
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
]}
>
{segment.text}

View File

@@ -41,7 +41,10 @@ export function DownloadToast() {
}
return (
<View style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]} pointerEvents="box-none">
<View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -61,7 +64,7 @@ export function DownloadToast() {
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: activeDownload.message ?? "Download failed"}
: (activeDownload.message ?? "Download failed")}
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>

View File

@@ -5,10 +5,7 @@ import DraggableFlatList, {
type RenderItemParams,
} from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -53,7 +50,7 @@ export function DraggableList<T>({
};
return renderItem(info);
},
[renderItem]
[renderItem],
);
const handleDragEnd = useCallback(
@@ -61,7 +58,7 @@ export function DraggableList<T>({
setIsDragging(false);
onDragEnd(newData);
},
[onDragEnd]
[onDragEnd],
);
const handleDragBegin = useCallback(() => {
@@ -74,12 +71,11 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (nestable
? (NestableDraggableFlatList as any)
: DraggableFlatList) as any;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
) as any;
return (
<ListComponent

View File

@@ -19,14 +19,8 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -79,7 +73,7 @@ function SortableItem<T>({
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
);
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -100,9 +94,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -152,7 +144,7 @@ export function DraggableList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -161,7 +153,7 @@ export function DraggableList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin]
[data, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -172,12 +164,8 @@ export function DraggableList<T>({
setDragItems(null);
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
@@ -185,7 +173,7 @@ export function DraggableList<T>({
}
}
},
[items, keyExtractor, onDragEnd]
[items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -204,9 +192,7 @@ export function DraggableList<T>({
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined

View File

@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>

View File

@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
}
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
serverId: string;
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
}
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
// Swipe gesture to close (swipe right on mobile)
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
]
],
);
// Desktop resize gesture (drag left edge)
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
);
const clampedWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(maxWidth, newWidth)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
@@ -351,8 +336,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -365,12 +349,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
@@ -380,12 +359,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
resolvedTab === "files" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>

View File

@@ -1,10 +1,6 @@
import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
const IS_WEB = Platform.OS === "web";
export function FileDropZone({
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,

View File

@@ -38,10 +38,7 @@ import {
RotateCw,
X,
} from "lucide-react-native";
import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -54,10 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
} from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
@@ -102,36 +96,32 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined
: undefined,
);
const {
@@ -156,9 +146,9 @@ export function FileExplorerPane({
const isDirectoryLoading = useCallback(
(path: string) =>
Boolean(
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
),
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path]
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
@@ -235,7 +225,7 @@ export function FileExplorerPane({
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const handleOpenFile = useCallback(
@@ -246,7 +236,7 @@ export function FileExplorerPane({
selectExplorerEntry(entry.path);
onOpenFile?.(entry.path);
},
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
);
const handleEntryPress = useCallback(
@@ -257,7 +247,7 @@ export function FileExplorerPane({
}
handleOpenFile(entry);
},
[handleOpenFile, handleToggleDirectory]
[handleOpenFile, handleToggleDirectory],
);
const handleCopyPath = useCallback(
@@ -266,10 +256,10 @@ export function FileExplorerPane({
buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot,
entryPath: path,
})
}),
);
},
[normalizedWorkspaceRoot]
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
@@ -288,13 +278,7 @@ export function FileExplorerPane({
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -320,7 +304,7 @@ export function FileExplorerPane({
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
})
}),
),
]);
return null;
@@ -342,7 +326,7 @@ export function FileExplorerPane({
easing: Easing.linear,
}),
-1,
false
false,
);
return;
}
@@ -366,7 +350,7 @@ export function FileExplorerPane({
if (finished) {
refreshIconRotation.value = 0;
}
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
@@ -394,10 +378,7 @@ export function FileExplorerPane({
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -495,7 +476,7 @@ export function FileExplorerPane({
selectedEntryPath,
theme.colors,
theme.spacing,
]
],
);
const handleBackFromError = useCallback(() => {
@@ -515,7 +496,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
@@ -524,7 +505,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
@@ -536,9 +517,7 @@ export function FileExplorerPane({
}
return (
<View
style={styles.container}
>
<View style={styles.container}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
@@ -632,16 +611,7 @@ export function FileExplorerPane({
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean }
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
@@ -783,7 +753,7 @@ function buildTreeRows({
sortOption,
path: entry.path,
depth: depth + 1,
})
}),
);
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -11,13 +11,37 @@ import {
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import {
highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
colorMap: Record<HighlightStyle, string>;
baseColor: string;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
@@ -37,28 +61,92 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
colorMap,
baseColor,
}: CodeLineProps) {
return (
<View style={codeLineStyles.line}>
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
>
{token.text}
</Text>
))}
</Text>
</View>
);
});
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
flex: 1,
},
}));
function FilePreviewBody({
preview,
isLoading,
showDesktopWebScrollbar,
isMobile,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
}) {
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
@@ -67,7 +155,7 @@ function FilePreviewBody({
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
@@ -88,6 +176,22 @@ function FilePreviewBody({
}
if (preview.kind === "text") {
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
{lines.map((tokens, index) => (
<CodeLine
key={index}
tokens={tokens}
lineNumber={index + 1}
gutterWidth={gutterWidth}
colorMap={colorMap}
baseColor={baseColor}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -96,17 +200,13 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>
<Text style={styles.codeText}>{preview.content}</Text>
</View>
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
@@ -114,7 +214,7 @@ function FilePreviewBody({
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
@@ -139,9 +239,7 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
@@ -182,8 +280,7 @@ export function FilePane({
workspaceRoot: string;
filePath: string;
}) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
@@ -200,7 +297,7 @@ export function FilePane({
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file"
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
},
@@ -220,6 +317,7 @@ export function FilePane({
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
/>
</View>
);
@@ -268,12 +366,6 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
color: theme.colors.foreground,
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],

View File

@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
}),
);
expect(noPrActions.primary).toBeNull();
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
}),
);
expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
}),
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");

View File

@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,

View File

@@ -9,6 +9,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -17,6 +19,7 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
@@ -49,7 +52,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
<Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
</View>
)}
</Pressable>
@@ -75,6 +80,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys
? <Shortcut chord={archiveShortcutKeys} />
: undefined
}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}

View File

@@ -1,4 +1,12 @@
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
memo,
type ReactElement,
} from "react";
import { useRouter } from "expo-router";
import {
View,
@@ -11,13 +19,11 @@ import {
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
Archive,
ChevronDown,
GitBranch,
GitCommitHorizontal,
GitMerge,
@@ -36,8 +42,12 @@ import {
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { DiffScroll } from "./diff-scroll";
import {
darkHighlightColors,
lightHighlightColors,
type HighlightStyle as HighlightStyleKey,
} from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
@@ -50,10 +60,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
@@ -76,53 +84,6 @@ interface HighlightedTextProps {
lineType: "add" | "remove" | "context" | "header";
}
// GitHub syntax highlight colors for dark/light modes
const darkHighlightColors: Record<HighlightStyle, string> = {
keyword: "#ff7b72",
comment: "#8b949e",
string: "#a5d6ff",
number: "#79c0ff",
literal: "#79c0ff",
function: "#d2a8ff",
definition: "#d2a8ff",
class: "#ffa657",
type: "#ff7b72",
tag: "#7ee787",
attribute: "#79c0ff",
property: "#79c0ff",
variable: "#c9d1d9",
operator: "#79c0ff",
punctuation: "#c9d1d9",
regexp: "#a5d6ff",
escape: "#79c0ff",
meta: "#8b949e",
heading: "#79c0ff",
link: "#a5d6ff",
};
const lightHighlightColors: Record<HighlightStyle, string> = {
keyword: "#cf222e",
comment: "#6e7781",
string: "#0a3069",
number: "#0550ae",
literal: "#0550ae",
function: "#8250df",
definition: "#8250df",
class: "#953800",
type: "#cf222e",
tag: "#116329",
attribute: "#0550ae",
property: "#0550ae",
variable: "#24292f",
operator: "#0550ae",
punctuation: "#24292f",
regexp: "#0a3069",
escape: "#0550ae",
meta: "#6e7781",
heading: "#0550ae",
link: "#0a3069",
};
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
@@ -132,7 +93,7 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
const colors = isDark ? darkHighlightColors : lightHighlightColors;
return colors[style] ?? baseColor;
return colors[style as HighlightStyleKey] ?? baseColor;
};
return (
@@ -146,7 +107,6 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
);
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
@@ -155,7 +115,15 @@ interface DiffFileSectionProps {
testID?: string;
}
function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
function DiffLineView({
line,
lineNumber,
gutterWidth,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
}) {
return (
<View
style={[
@@ -167,20 +135,18 @@ function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineN
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}>
<Text
style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText
tokens={line.tokens}
baseStyle={null}
lineType={line.type}
/>
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
) : (
<Text
style={[
@@ -216,10 +182,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
return (
<View
style={[
styles.fileSectionHeaderContainer,
isExpanded && styles.fileSectionHeaderExpanded,
]}
style={[styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
@@ -228,10 +191,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [
styles.fileHeader,
pressed && styles.fileHeaderPressed,
]}
style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={(event) => {
@@ -265,9 +225,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
<View style={styles.fileHeaderLeft}>
<Text style={styles.fileName}>{file.path.split("/").pop()}</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
{file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""}
</Text>
{file.isNew && (
<View style={styles.newBadge}>
@@ -301,41 +259,6 @@ function DiffFileBody({
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Not inside ExplorerSidebarAnimationProvider, which is fine
}
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId]
);
return (
<View
@@ -359,10 +282,13 @@ function DiffFileBody({
const linesContent = (() => {
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
maxLineNo = Math.max(
maxLineNo,
hunk.oldStart + hunk.oldCount,
hunk.newStart + hunk.newCount,
);
}
const digitCount = Math.max(1, String(maxLineNo).length);
const gutterWidth = digitCount * 8 + 12;
const gutterWidth = lineNumberGutterWidth(maxLineNo);
return file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
@@ -394,35 +320,24 @@ function DiffFileBody({
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{linesContent}
</View>
<View style={styles.linesContainer}>{linesContent}</View>
</View>
);
}
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
<DiffScroll
scrollViewWidth={scrollViewWidth}
onScrollViewWidthChange={setScrollViewWidth}
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
<View
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
>
{linesContent}
</View>
</ScrollView>
</DiffScroll>
);
})()}
</View>
@@ -442,8 +357,7 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
@@ -466,8 +380,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
}, []);
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd });
const {
status,
isLoading: isStatusLoading,
isFetching: isStatusFetching,
isError: isStatusError,
error: statusError,
refresh: refreshStatus,
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
@@ -558,7 +478,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey]
[shipDefaultStorageKey],
);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
@@ -600,7 +520,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
diffScrollbarMetrics.onScroll(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
[diffScrollbarMetrics, showDesktopWebScrollbar],
);
const handleDiffListLayout = useCallback(
@@ -614,7 +534,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
diffScrollbarMetrics.onLayout(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
[diffScrollbarMetrics, showDesktopWebScrollbar],
);
const computeHeaderOffset = useCallback(
@@ -632,7 +552,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}
return Math.max(0, offset);
},
[expandedByPath, files]
[expandedByPath, files],
);
const handleToggleExpanded = useCallback(
@@ -666,7 +586,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
[path]: nextExpanded,
}));
},
[computeHeaderOffset, expandedByPath]
[computeHeaderOffset, expandedByPath],
);
const allExpanded = useMemo(() => {
@@ -699,22 +619,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}, [autoDiffMode]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
@@ -818,19 +738,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
/>
);
},
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
[]
[],
);
const hasChanges = files.length > 0;
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
@@ -850,9 +770,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
if (!branchLabel || !baseRefLabel) {
return undefined;
}
return branchLabel === baseRefLabel
? undefined
: `${branchLabel} -> ${baseRefLabel}`;
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}, [baseRefLabel, branchLabel]);
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
@@ -867,14 +785,10 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled || archiveStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled = actionsDisabled || pushStatus === "pending";
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
@@ -932,9 +846,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar
? diffScrollbarMetrics.onContentSizeChange
: undefined
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
@@ -1026,11 +938,38 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
},
});
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
aheadCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
theme.colors.foregroundMuted,
]);
@@ -1046,9 +985,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
{branchLabel}
</Text>
</View>
{isGit ? (
<GitActionsSplitButton gitActions={gitActions} />
) : null}
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
</View>
) : null}
@@ -1066,16 +1003,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
accessibilityRole="button"
accessibilityLabel="Diff mode"
>
<Text style={styles.diffStatusText}>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={260}
testID="changes-diff-status-menu"
>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
@@ -1124,9 +1057,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
@@ -1143,9 +1082,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>
{bodyContent}
@@ -1207,13 +1144,18 @@ const styles = StyleSheet.create((theme) => ({
diffModeTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
marginLeft: theme.spacing[3] - theme.spacing[1],
marginVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
height: {
xs: 28,
sm: 28,
md: 24,
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -1226,6 +1168,7 @@ const styles = StyleSheet.create((theme) => ({
},
diffStatusText: {
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.25,
color: theme.colors.foregroundMuted,
},
diffStatusIconHidden: {
@@ -1243,19 +1186,25 @@ const styles = StyleSheet.create((theme) => ({
expandAllButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
marginVertical: theme.spacing[2],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
paddingVertical: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
@@ -1429,6 +1378,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.palette.green[400],
@@ -1443,6 +1393,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
userSelect: "text",
},
addLineContainer: {
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green

View File

@@ -11,21 +11,14 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (

View File

@@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) {
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
/>
</View>
);
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<HeaderToggleButton
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={

View File

@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -37,9 +32,7 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();

View File

@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line
x1={9}
y1={9.5}
x2={15}
y2={9.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Line
x1={12}
y1={6.5}
x2={12}
y2={12.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line
x1={9}
y1={16}
x2={15}
y2={16}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg>
);
}

View File

@@ -16,12 +16,12 @@ export function KeyboardShortcutsDialog() {
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
[isDesktopApp, isMac],
);
return (
<AdaptiveModalSheet
title="Keyboard shortcuts"
title="Shortcuts"
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"

View File

@@ -9,103 +9,109 @@ import {
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
} from "react";
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
buildHostAgentsRoute,
useSidebarWorkspacesList,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from '@/utils/host-routes'
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
const MIN_CHAT_WIDTH = 400;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps {
selectedAgentId?: string
selectedAgentId?: string;
}
interface HostOption {
id: string
label: string
description: string
id: string;
label: string;
description: string;
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: HostOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleSettings: () => void;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
handleViewMoreNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
isOpen: boolean;
handleViewMore: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
export const LeftSidebar = memo(function LeftSidebar({
selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const mobileView = usePanelStore((state) => state.mobileView)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
@@ -113,128 +119,131 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|'),
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|')
)
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
.join("|"),
);
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
return null
return null;
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch
return routeMatch;
}
}
return daemons[0] ?? null
}, [activeServerIdFromPath, daemons])
const activeServerId = activeDaemon?.serverId ?? null
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return 'No host'
const trimmed = activeDaemon.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
}, [activeDaemon])
if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle'
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === 'online'
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === 'connecting'
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
),
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
[daemons, runtime, runtimeConnectionStatusSignature],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
});
const {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
} = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false)
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true)
refreshAll()
}, [refreshAll])
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh])
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId)
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
closeToAgent()
void openProjectPicker()
}, [closeToAgent, openProjectPicker])
closeToAgent();
void openProjectPicker();
}, [closeToAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker()
}, [openProjectPicker])
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
return;
}
closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostAgentsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSessionsRoute(activeServerId) as any);
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false)
router.push(nextPath as any)
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
)
[pathname],
);
const sharedProps = {
theme,
@@ -252,10 +261,9 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
};
if (isMobile) {
return (
@@ -269,7 +277,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
);
}
return (
@@ -280,8 +288,46 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
);
});
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
isActive && styles.newAgentButtonActive,
]}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={onPress}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
);
}
function MobileSidebar({
theme,
@@ -299,7 +345,6 @@ function MobileSidebar({
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
@@ -310,6 +355,7 @@ function MobileSidebar({
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const {
translateX,
backdropOpacity,
@@ -318,22 +364,22 @@ function MobileSidebar({
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
closeToAgent();
}, [closeToAgent]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
return;
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
@@ -341,7 +387,7 @@ function MobileSidebar({
handleViewMoreNavigate,
translateX,
windowWidth,
])
]);
const closeGesture = useMemo(
() =>
@@ -350,62 +396,62 @@ function MobileSidebar({
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch) {
return
return;
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (deltaX >= 10) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
animateToClose();
runOnJS(handleClose)();
} else {
animateToOpen()
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false
isGesturing.value = false;
}),
[
isOpen,
@@ -419,29 +465,29 @@ function MobileSidebar({
animateToClose,
animateToOpen,
handleClose,
]
)
],
);
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}))
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -457,25 +503,7 @@ function MobileSidebar({
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
@@ -486,7 +514,7 @@ function MobileSidebar({
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -537,7 +565,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
@@ -561,7 +589,7 @@ function MobileSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -575,7 +603,7 @@ function MobileSidebar({
</Animated.View>
</GestureDetector>
</View>
)
);
}
function DesktopSidebar({
@@ -594,7 +622,6 @@ function DesktopSidebar({
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
@@ -602,43 +629,64 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const startWidthRef = useRef(sidebarWidth);
const resizeWidth = useSharedValue(sidebarWidth);
useEffect(() => {
resizeWidth.value = sidebarWidth;
}, [sidebarWidth, resizeWidth]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = sidebarWidth;
resizeWidth.value = sidebarWidth;
})
.onUpdate((event) => {
// Dragging right (positive translationX) increases width
const newWidth = startWidthRef.current + event.translationX;
const maxWidth = Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
}),
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
if (!isOpen) {
return null
return null;
}
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
@@ -649,7 +697,6 @@ function DesktopSidebar({
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -698,7 +745,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
@@ -722,7 +769,7 @@ function DesktopSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -732,63 +779,78 @@ function DesktopSidebar({
anchorRef={hostTriggerRef}
/>
</View>
</View>
)
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</Animated.View>
);
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden',
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
resizeHandle: {
position: "absolute",
right: -5,
top: 0,
bottom: 0,
width: 10,
zIndex: 10,
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: 'center',
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: 'none',
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingRight: theme.spacing[1],
paddingLeft: theme.spacing[3],
flexShrink: 0,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newAgentButtonHovered: {},
newAgentButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
@@ -797,10 +859,16 @@ const styles = StyleSheet.create((theme) => ({
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surface1,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surface1,
},
hostTrigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
@@ -822,9 +890,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
sidebarFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
@@ -837,16 +905,16 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
@@ -872,19 +940,19 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: 'center',
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}))
}));

File diff suppressed because it is too large Load Diff

View File

@@ -40,11 +40,7 @@ import {
TriangleAlertIcon,
Scissors,
} from "lucide-react-native";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import Animated, {
Easing,
cancelAnimation,
@@ -53,23 +49,14 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import {
createMarkdownStyles,
} from "@/styles/markdown-styles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
hasMeaningfulToolCallDetail,
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
axis === "x"
? element.scrollWidth - element.clientWidth
: element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({
image,
}: {
image: UserMessageImageAttachment;
}) {
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton =
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return (
<View
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
!resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup
? { marginBottom: theme.spacing[1] }
: undefined,
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
]}
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
]}
>
{images.map((image, index) => (
<View
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
@@ -475,11 +437,7 @@ function MarkdownLink({
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
return (
<Text
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
</Text>
);
@@ -492,25 +450,25 @@ function MarkdownLink({
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
{children}
</Text>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
</Pressable>
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
content: string,
): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
}
const matches = markdownParser.linkify.match(trimmed) as
| Array<{ index: number; lastIndex: number; url: string }>
| null;
const matches = markdownParser.linkify.match(trimmed) as Array<{
index: number;
lastIndex: number;
url: string;
}> | null;
if (!matches || matches.length !== 1) {
return null;
}
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
copied
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
}
>
{({ hovered }) => {
@@ -759,42 +715,46 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo(
() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
return defaultValidateLink(url);
};
return parser;
},
[]
);
return defaultValidateLink(url);
};
return parser;
}, []);
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
const handleLinkPress = useCallback(
(url: string) => {
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, [onInlinePathPress, workspaceRoot]);
},
[onInlinePathPress, workspaceRoot],
);
const markdownRules = useMemo(() => {
return {
@@ -803,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
@@ -814,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
@@ -828,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
@@ -842,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
@@ -853,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => {
const content = node.content ?? "";
const isLinkedInlineCode =
nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) &&
typeof parent?.attributes?.href === "string");
const parsed = onInlinePathPress && !isLinkedInlineCode
? parseInlinePathToken(content)
: null;
(!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
if (parsed) {
return (
@@ -870,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
@@ -895,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
}
return (
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{content}
</Text>
);
},
bullet_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered
? styles.ordered_list_icon
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
link: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink
key={node.key}
href={node.attributes?.href ?? ""}
@@ -978,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
: child
? cloneElement(child, {
style: [(child.props as any).style, { color: styles.link.color }],
} as any)
: child,
)}
</MarkdownLink>
),
@@ -991,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
assistantMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown
@@ -1102,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
@@ -1142,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
};
const displayMessage =
type === "artifact" && artifactType && title
? `${artifactType}: ${title}`
: message;
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
const isInteractive = type === "artifact" || metadata;
@@ -1165,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text
style={[
activityLogStylesheet.messageText,
{ color: config.color },
]}
>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
@@ -1395,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
);
const [labelRowWidth, setLabelRowWidth] = useState(0);
const [labelRowHeight, setLabelRowHeight] = useState(0);
@@ -1419,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars =
label.trim().length + (secondaryLabel?.trim().length ?? 0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
const shimmerDuration = Math.max(
1,
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment)
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
);
const nativeShimmerPeakWidth = Math.max(
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0)
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
const isWebShimmer = isLoading && Platform.OS === "web";
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const isNativeShimmer =
shouldMeasureNativeShimmer &&
labelRowWidth > 0 &&
labelRowHeight > 0;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
? secondaryOffsetX + secondaryWidth
: labelOffsetX + labelWidth;
const webShimmerSpanWidth = Math.max(
1,
webShimmerSpanEndX - webShimmerSpanStartX
);
const webShimmerPeakWidth = Math.max(
42,
Math.min(120, webShimmerSpanWidth * 0.22)
);
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
const webShimmerTrackEnd = webShimmerSpanEndX;
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
setLabelRowHeight((previous) =>
Math.abs(previous - height) > 0.5 ? height : previous
);
}, [shouldMeasureNativeShimmer]);
const handleLabelRowLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
},
[shouldMeasureNativeShimmer],
);
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setLabelWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer]);
const handleLabelLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer],
);
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setSecondaryWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer, secondaryLabel]);
const handleSecondaryLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer, secondaryLabel],
);
useEffect(() => {
if (!isWebShimmer) {
@@ -1513,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
easing: Easing.linear,
}),
-1,
false
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [
isNativeShimmer,
labelRowWidth,
nativeShimmerPeakWidth,
shimmerDuration,
shimmerTranslateX,
]);
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
@@ -1598,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
: expandableBadgeStylesheet.containerSpacing),
style,
],
[isLastInSequence, resolvedDisableOuterSpacing, style]
[isLastInSequence, resolvedDisableOuterSpacing, style],
);
const pressableStyle = useMemo(
() => [
expandableBadgeStylesheet.pressable,
isPressed && isInteractive
? expandableBadgeStylesheet.pressablePressed
: null,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
],
[isExpanded, isInteractive, isPressed]
[isExpanded, isInteractive, isPressed],
);
const accessibilityState = useMemo(
() => (isInteractive ? { expanded: isExpanded } : undefined),
[isExpanded, isInteractive]
[isExpanded, isInteractive],
);
const isActive = isHovered || isExpanded;
@@ -1625,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading,
],
[isActive, isLoading]
[isActive, isLoading],
);
const secondaryLabelStyle = useMemo(
@@ -1633,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
[isActive],
);
const shimmerLabelTextStyle = useMemo(
@@ -1643,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerLabelStyle,
],
[isLoading, shimmerLabelStyle]
[isLoading, shimmerLabelStyle],
);
const shimmerSecondaryTextStyle = useMemo(
@@ -1652,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerSecondaryStyle,
],
[shimmerSecondaryStyle]
[shimmerSecondaryStyle],
);
const nativeShimmerTrackStyle = useMemo(
@@ -1660,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeShimmerMaskStyle = useMemo(
@@ -1668,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[]
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
{ color: "#000000", opacity: 1 },
],
[]
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
@@ -1690,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
);
const chevronStyle = useMemo(
@@ -1698,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
],
[isExpanded]
[isExpanded],
);
const IconComponent = icon;
@@ -1716,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={containerStyle} testID={testID}>
<Pressable
onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
@@ -1763,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={expandableBadgeStylesheet.spacer} />
)}
{isWebShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<Text
style={shimmerLabelTextStyle}
numberOfLines={1}
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={shimmerSecondaryTextStyle}
numberOfLines={1}
>
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1786,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
) : null}
{isNativeShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text
style={nativeLabelMaskStyle}
numberOfLines={1}
>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={nativeSecondaryMaskStyle}
numberOfLines={1}
>
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1813,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
}
>
<View
style={nativeShimmerTrackStyle}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -1846,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{isInteractive && isHovered ? (
<ChevronRight
size={14}
color={theme.colors.foreground}
style={chevronStyle}
/>
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
@@ -1868,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
}, areExpandableBadgePropsEqual);
function areExpandableBadgePropsEqual(
previous: ExpandableBadgeProps,
next: ExpandableBadgeProps
) {
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
if (previous.label !== next.label) return false;
if (previous.secondaryLabel !== next.secondaryLabel) return false;
if (previous.icon !== next.icon) return false;
@@ -1918,13 +1763,14 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -1940,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail =
effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayDetail = effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayModel = useMemo(
() =>
@@ -1957,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
metadata,
cwd,
}),
[toolName, status, error, displayDetail, metadata, cwd]
[toolName, status, error, displayDetail, metadata, cwd],
);
const displayName = displayModel.displayName;
const summary = displayModel.summary;
@@ -1971,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails =
Boolean(error) ||
hasMeaningfulToolCallDetail(effectiveDetail);
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => {

View File

@@ -18,12 +18,7 @@ export function ModeSelectorModal({
const { theme } = useUnistyles();
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
onModeChange(mode.id);
onClose();
}}
style={[
styles.modeItem,
isActive && styles.modeItemActive,
]}
style={[styles.modeItem, isActive && styles.modeItemActive]}
>
<Text
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
{mode.label}
</Text>
{mode.description && (
<Text
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
{mode.description}
</Text>
)}

View File

@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
<Text style={styles.helper}>
Optional. You can rename this later in Settings.
</Text>
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>

View File

@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
<AdaptiveModalSheet
title="Paste pairing link"
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<View style={styles.field}>

View File

@@ -1,13 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null);
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id
(workspace) => workspace.projectRootPath || workspace.id,
);
}, [workspaces]);
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
limit: 30,
});
return (
result.entries?.flatMap((entry) =>
entry.kind === "directory" ? [entry.path] : []
) ?? []
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
},
enabled: Boolean(client) && isConnected && open,
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[query, directorySuggestionsQuery.data, recommendedPaths]
[query, directorySuggestionsQuery.data, recommendedPaths],
);
const handleClose = useCallback(() => {
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
[client, openProject, serverId, setOpen]
[client, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
)
return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
if (!serverId) return null;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
},
]}
>
<View
style={[styles.header, { borderBottomColor: theme.colors.border }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
ref={inputRef}
value={query}
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
/>
</View>
<Text
style={[
styles.rowText,
{ color: theme.colors.foreground },
]}
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}

View File

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

View File

@@ -1,12 +1,5 @@
import { useState, useCallback } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<
"submit" | "dismiss" | null
>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
},
[]
);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -182,9 +165,7 @@ export function QuestionFormCard({
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text
style={[styles.questionText, { color: theme.colors.foreground }]}
>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
@@ -202,19 +183,12 @@ export function QuestionFormCard({
},
pressed && styles.optionItemPressed,
]}
onPress={() =>
toggleOption(qIndex, optIndex, q.multiSelect)
}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text
style={[
styles.optionLabel,
{ color: theme.colors.foreground },
]}
>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
@@ -230,10 +204,7 @@ export function QuestionFormCard({
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check
size={16}
color={theme.colors.foregroundMuted}
/>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
@@ -245,9 +216,8 @@ export function QuestionFormCard({
style={[
styles.otherInput,
{
borderColor: otherText.length > 0
? theme.colors.borderAccent
: theme.colors.border,
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
@@ -264,19 +234,12 @@ export function QuestionFormCard({
);
})}
<View
style={[
styles.actionsContainer,
!isMobile && styles.actionsContainerDesktop,
]}
>
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered
? theme.colors.surface2
: theme.colors.surface1,
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
@@ -285,19 +248,11 @@ export function QuestionFormCard({
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text
style={[
styles.actionText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
@@ -311,12 +266,8 @@ export function QuestionFormCard({
styles.actionButton,
{
backgroundColor:
hovered && !disabled
? theme.colors.surface2
: theme.colors.surface1,
borderColor: disabled
? theme.colors.border
: theme.colors.borderAccent,
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1,
},
pressed && !disabled ? styles.optionItemPressed : null,
@@ -326,27 +277,18 @@ export function QuestionFormCard({
disabled={!allAnswered || isResponding}
>
{respondingAction === "submit" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
/>
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={
allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted,
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>

View File

@@ -73,8 +73,7 @@ export function ResizeHandle({
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
@@ -91,7 +90,7 @@ export function ResizeHandle({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
[direction, groupId, index, onResizeSplit, sizes],
);
return (
@@ -107,9 +106,7 @@ export function ResizeHandle({
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>

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