Compare commits

...

146 Commits

Author SHA1 Message Date
Mohamed Boudra
fed842e9f1 chore(release): cut 0.1.16 2026-02-22 18:43:55 +07:00
Mohamed Boudra
f4b98d1d81 docs(changelog): add 0.1.16 release notes 2026-02-22 18:43:26 +07:00
Mohamed Boudra
bfea00df78 Fix user-message echo dedupe with canonical message IDs 2026-02-22 18:05:12 +07:00
Mohamed Boudra
0c710b09f5 refactor(app): introduce attachment storage layer and draft lifecycle management
Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
2026-02-22 16:22:21 +07:00
Mohamed Boudra
2652bdda2e desktop: make tauri attachments filesystem-only 2026-02-22 15:25:31 +07:00
Mohamed Boudra
6f2944ec7f chore(desktop): configure tauri updater pubkey 2026-02-22 14:43:42 +07:00
Mohamed Boudra
40f376b824 Preserve authoritative agent title across refresh 2026-02-22 14:10:42 +07:00
Mohamed Boudra
8ee9e7274d Polish desktop settings update/local-daemon sections 2026-02-22 13:28:14 +07:00
Mohamed Boudra
44c3ee670f refactor: remove debug logging and simplify event identifier extraction 2026-02-22 13:10:20 +07:00
YK
8bcfde8184 perf(app): reduce streaming rerenders and scroll churn (#67)
Amp-Thread-ID: https://ampcode.com/threads/T-019c793a-d087-762a-882a-8cc11e6101db

Co-authored-by: Ubuntu <ubuntu@dev-yk.tail388b2e.ts.net>
2026-02-22 13:09:49 +07:00
Mohamed Boudra
55b450c8b9 fix(app): stabilize hook order in agent archive flows 2026-02-22 12:37:26 +07:00
Mohamed Boudra
74a9a3c33b refactor: remove permission timeout logic and rely on abort handler (#62) 2026-02-22 12:17:37 +07:00
Mohamed Boudra
1fdad9e2a7 Merge pull request #68 from badri/fix/pin-opencode-sdk-1.2.6
fix: pin @opencode-ai/sdk to 1.2.6 to fix startup crash
2026-02-22 12:11:53 +07:00
Mohamed Boudra
52336425bc Improve wait output with recent activity snapshot 2026-02-22 12:10:38 +07:00
Mohamed Boudra
afc74d67e2 Merge pull request #69 from getpaseo/feat/auto-update-daemon-app
Add desktop app + local daemon update controls in Settings
2026-02-22 12:05:39 +07:00
Mohamed Boudra
88f7763430 polish(app): align desktop updates copy and labeling 2026-02-22 12:05:00 +07:00
Mohamed Boudra
e9e267ff14 Merge pull request #70 from getpaseo/diagnostics/android-hang-cleanup-policy
Centralize perf diagnostics and activity coalescing
2026-02-22 11:57:33 +07:00
Mohamed Boudra
d6b7c2ed2d refactor(app): centralize perf diagnostics and activity coalescing 2026-02-22 11:52:08 +07:00
Mohamed Boudra
18fbd1dfbf docs: remove obsolete daemon session connection architecture specs 2026-02-22 11:50:15 +07:00
Mohamed Boudra
409a3ac5df refactor(app): isolate desktop settings update and permission flows 2026-02-22 11:45:37 +07:00
Mohamed Boudra
ccd42751d2 Harden Claude provider lifecycle and clean temp artifacts 2026-02-22 11:44:56 +07:00
Mohamed Boudra
c1e4b0fcbf server: only emit attention notifications for ui agents 2026-02-22 11:16:57 +07:00
Mohamed Boudra
0d28dae444 feat(app): add tauri web overlay module resolution 2026-02-22 11:12:53 +07:00
Mohamed Boudra
1c83e10c6d style(settings): align updates section with app UI patterns 2026-02-22 10:44:13 +07:00
Mohamed Boudra
231b83dbc2 feat(desktop): add app and local daemon update controls in Settings 2026-02-22 10:40:12 +07:00
Mohamed Boudra
d7e2ab4437 feat: add thinking option support and autonomous run handling 2026-02-21 22:09:48 +07:00
Mohamed Boudra
c3c8103563 refactor: migrate from clientSessionKey to clientId identity 2026-02-21 15:33:29 +07:00
Mohamed Boudra
e906bffbb9 server: add ws hello/welcome message schemas 2026-02-21 11:48:18 +07:00
Mohamed Boudra
c123c98f5f relay: rename encrypted handshake message types 2026-02-21 11:47:26 +07:00
Mohamed Boudra
e6a7c4de42 feat: add delayed toast for history refresh with proper header spacing 2026-02-21 10:35:30 +07:00
Mohamed Boudra
5dae7f854f refactor: remove daemon connections context, use host runtime directly 2026-02-21 08:52:35 +07:00
Mohamed Boudra
0350f00840 feat: implement daemon connectivity updates and relay protocol versioning 2026-02-20 19:27:56 +07:00
Lakshmi Narasimhan
cc7483ea12 fix: pin @opencode-ai/sdk to 1.2.6 to fix startup crash
Versions 1.2.7–1.2.9 have a broken npm publish where compiled files
land in dist/src/ but the package exports map references dist/v2/,
causing ERR_MODULE_NOT_FOUND on startup. Pin to 1.2.6 which has the
correct layout.

Closes #64

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:35:22 +05:30
Mohamed Boudra
6dad3212da fix: interrupt running agents before archive 2026-02-20 13:34:06 +07:00
Mohamed Boudra
e22647af62 test: add real multi-provider UI action stress coverage 2026-02-20 13:16:39 +07:00
Mohamed Boudra
d128a32d2f Unify app connection state around host runtime 2026-02-20 10:37:59 +07:00
Mohamed Boudra
6b23509b96 Merge branch 'debug/android-auto-connect-status' 2026-02-19 21:21:00 +07:00
Mohamed Boudra
f8e3204572 refactor: extract host runtime controller for connection management 2026-02-19 21:19:51 +07:00
Mohamed Boudra
603e95de09 refactor(agent-screen): extract state machine for agent loading flow 2026-02-19 21:17:51 +07:00
Mohamed Boudra
bff1a5accc docs(changelog): update 0.1.15 release notes with final features 2026-02-19 13:25:33 +07:00
Mohamed Boudra
5db14ee50c docs: trim changelog to user-facing items and clarify release notes guidelines 2026-02-19 13:13:49 +07:00
Mohamed Boudra
810ac63e21 chore(release): cut 0.1.15 2026-02-19 13:07:40 +07:00
Mohamed Boudra
0f66aa9d67 docs(changelog): add 0.1.15 release notes 2026-02-19 13:07:25 +07:00
Mohamed Boudra
4df517d84b docs: add CHANGELOG update step to release checklist 2026-02-19 13:05:50 +07:00
Mohamed Boudra
e1d51dfec9 chore: bump version to 0.1.14 2026-02-19 13:04:08 +07:00
Mohamed Boudra
7d1122a371 fix(app): redirect archived agent screen to draft 2026-02-19 13:03:53 +07:00
Mohamed Boudra
1473820cc4 app: make draft screen unhandled areas draggable in Tauri 2026-02-19 13:03:15 +07:00
Mohamed Boudra
ff0be38cff Simplify working directory placeholder and empty state messaging 2026-02-19 12:59:48 +07:00
Mohamed Boudra
7bda40bd4e ci: deploy app workflow only on release tags 2026-02-19 12:58:47 +07:00
Mohamed Boudra
fb6b5ca886 Rename sidebar to LeftSidebar and hide empty filters 2026-02-19 12:57:23 +07:00
Mohamed Boudra
8d379a6215 feat(website): redesign homepage get-started as two-step flow
Step 1 installs the daemon, Step 2 shows download options. Replace
iOS/Android text buttons with icon-only Apple and Google Play buttons
that show a "Coming soon" tooltip on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:46:09 +07:00
Mohamed Boudra
d4a9ee482a Fix duplicate /rewind user message and add real e2e regression 2026-02-19 12:32:48 +07:00
Mohamed Boudra
2f98f073ce ci(release): sync github release notes from changelog 2026-02-19 11:01:17 +07:00
Mohamed Boudra
719ae73c7d feat(website): simplify github nav and changelog headings 2026-02-19 10:16:59 +07:00
Mohamed Boudra
dd5218ebdb docs(website): add changelog page and publish v0.1.14 notes 2026-02-19 10:07:03 +07:00
Mohamed Boudra
f1f4afaaa9 chore(release): cut 0.1.14 2026-02-19 09:55:55 +07:00
Mohamed Boudra
b6b0933a39 Add desktop web scrollbar handles to file preview pane 2026-02-19 09:55:08 +07:00
Mohamed Boudra
24a8db5763 refactor: extract explorer open gesture and standardize icon sizes 2026-02-19 09:53:44 +07:00
Mohamed Boudra
08d4cfac11 fix(settings): show daemon version badge only when available 2026-02-19 09:44:43 +07:00
Mohamed Boudra
02bcfcd5af refactor(scrollbar): improve scroll tracking and animation timing 2026-02-19 09:02:14 +07:00
Mohamed Boudra
a1fdeeb7ae fix(dictation): avoid timeout from dangling non-final segments 2026-02-18 21:21:27 +07:00
Mohamed Boudra
bcae95c372 refactor(app): extract theme toggle to SegmentedControl component 2026-02-18 20:38:46 +07:00
Mohamed Boudra
62d69410d3 fix(git): show Sync only when branch diverges from origin 2026-02-18 20:36:24 +07:00
Mohamed Boudra
e8c4ae2024 fix(app): prefill new agent cwd with main repo for worktrees 2026-02-18 20:35:49 +07:00
Mohamed Boudra
e22f183bb2 Fix autocomplete popover stability and workspace file suggestion ranking 2026-02-18 20:32:19 +07:00
Mohamed Boudra
cbdf8ea0bc Improve web scrollbar visibility and grab interaction 2026-02-18 20:31:04 +07:00
Mohamed Boudra
e1bd459bfd fix: simplify git sync button label when on base branch 2026-02-18 19:57:05 +07:00
Mohamed Boudra
f070e86746 refactor: extract keyboard shortcut routing logic to separate module 2026-02-18 19:55:00 +07:00
Mohamed Boudra
e26dcde2c8 fix(server): use parent pid for lock ownership when spawned as child process 2026-02-18 19:40:46 +07:00
Mohamed Boudra
005875de2b Merge pull request #61 from getpaseo/fix/agent-archive-loading-state
Fix archive UX with shared pending state and agent-screen overlay
2026-02-18 18:27:03 +07:00
Mohamed Boudra
dd4cc82ce8 Merge pull request #60 from getpaseo/feature/image-paste-attachment
feat(app): paste images into prompt attachments
2026-02-18 18:26:16 +07:00
Mohamed Boudra
52044e2a7d Merge remote-tracking branch 'origin/main' into pr60-sync
# Conflicts:
#	packages/app/src/components/message-input.tsx
2026-02-18 18:25:30 +07:00
Mohamed Boudra
95eec2b6c0 Merge pull request #59 from getpaseo/implement/custom-scrollbars-web-desktop
feat(app): shared custom overlay scrollbars on desktop web panes
2026-02-18 18:22:37 +07:00
Mohamed Boudra
f2fa7bb79e Merge remote-tracking branch 'origin/main' into pr59-sync
# Conflicts:
#	packages/app/src/components/git-diff-pane.tsx
2026-02-18 18:21:48 +07:00
Mohamed Boudra
b1afb2673d Merge pull request #58 from getpaseo/implement/workspace-file-autocomplete
Add @ workspace file autocomplete for agent chat
2026-02-18 18:20:28 +07:00
Mohamed Boudra
32a0e7e8ef Merge remote-tracking branch 'origin/main' into pr58-sync
# Conflicts:
#	packages/server/src/shared/messages.ts
2026-02-18 18:19:41 +07:00
Mohamed Boudra
b09cb77514 Merge pull request #57 from getpaseo/investigate/git-workflow-ux-improvements
Improve post-ship worktree flow and merged PR handling
2026-02-18 18:18:06 +07:00
Mohamed Boudra
8e02a59a93 Merge pull request #56 from getpaseo/research/claude-rewind-command
Add Claude provider /rewind command support
2026-02-18 18:17:23 +07:00
Mohamed Boudra
4affac9ce9 fix(desktop): improve tauri permissions and settings UX 2026-02-18 18:13:20 +07:00
Mohamed Boudra
229e747244 fix(app): add shared archive pending state across sidebar and agent screen 2026-02-18 18:02:47 +07:00
Mohamed Boudra
69e009990c feat(app): support pasting images into prompt attachments 2026-02-18 17:55:13 +07:00
Mohamed Boudra
5079ca386b feat(app): add shared desktop web overlay scroll handles 2026-02-18 15:10:20 +07:00
Mohamed Boudra
d30bb84741 feat(app): add @ workspace file autocomplete in agent chat 2026-02-18 13:25:55 +07:00
Mohamed Boudra
e36d318001 Improve post-ship worktree UX and merged PR detection 2026-02-18 13:12:36 +07:00
Mohamed Boudra
e88a0c1db6 feat(server): add Claude /rewind command support 2026-02-18 13:08:40 +07:00
Mohamed Boudra
b1955cf74d fix: unify agent attention notification payloads (#55)
* fix: simplify tmux session creation in paseo.json

* fix: unify agent attention notification payloads
2026-02-18 12:28:27 +07:00
Mohamed Boudra
4305a37ec3 Enable explorer sidebar on draft screen after cwd selection (#54)
* fix: simplify tmux session creation in paseo.json

* feat(app): enable draft explorer sidebar from working directory

* chore: align paseo config with main
2026-02-18 12:27:19 +07:00
Mohamed Boudra
96a80036f6 Improve desktop command autocomplete to match combobox behavior (#53)
* feat(app): align command autocomplete with desktop combobox

* refactor(app): extract shared autocomplete UI and command hook

* refactor(app): rename autocomplete hooks to generic naming
2026-02-18 11:58:57 +07:00
Mohamed Boudra
dab08865c7 Merge branch 'main' of github.com:getpaseo/paseo 2026-02-18 11:19:25 +07:00
Mohamed Boudra
483966a05b fix(app): confirm terminal close for running shell commands (#52) 2026-02-18 11:13:48 +07:00
Mohamed Boudra
08a35c9dbf Merge pull request #51 from getpaseo/implement/issue-43
fix: archive worktree when last agent is archived
2026-02-18 11:11:10 +07:00
Mohamed Boudra
d3704456a3 fix(server): archive worktree when last agent is archived 2026-02-18 11:06:57 +07:00
Mohamed Boudra
62e3b214ff fix: simplify tmux session creation in paseo.json 2026-02-18 11:06:22 +07:00
Mohamed Boudra
64b36d0df7 refactor: remove executeCommand in favor of standard run/stream path 2026-02-18 10:57:21 +07:00
Mohamed Boudra
803e31e61c fix(server): hide hidden directories from cwd suggestions 2026-02-18 10:27:22 +07:00
Mohamed Boudra
79b64d3e38 feat: route slash commands through executeCommand in run/stream 2026-02-18 10:03:54 +07:00
Mohamed Boudra
0337204281 ci: remove Desktop from GitHub release title 2026-02-18 10:03:54 +07:00
Mohamed Boudra
2324065e0a Merge pull request #33 from getpaseo/show-image-previews-in-optimistic-messages
Show image previews in optimistic user messages
2026-02-18 10:03:07 +07:00
Mohamed Boudra
d51fd74f55 Merge origin/main into show-image-previews-in-optimistic-messages 2026-02-18 10:02:46 +07:00
Mohamed Boudra
792f5d8d6e Merge pull request #32 from getpaseo/shaky-penguin
Fix worktree archive terminal cleanup and tighten worktree setup
2026-02-18 09:59:02 +07:00
Mohamed Boudra
63b088a646 Merge pull request #45 from getpaseo/fix/terminal-resize-agent-switch-clean
fix: terminal shrinks after switching agents
2026-02-18 09:58:59 +07:00
Mohamed Boudra
fdb3c6830d Merge pull request #48 from getpaseo/feature/commands-in-draft-screen-clean
Make slash commands available in the new-agent draft screen
2026-02-18 09:58:56 +07:00
Mohamed Boudra
ab8ae975f6 Merge pull request #49 from getpaseo/feat/issue-41-tdd-clean
fix: hash worktree root by cwd to prevent path clashes
2026-02-18 09:58:35 +07:00
Mohamed Boudra
ecd276e3d2 refactor(server): route draft command listing through agent manager 2026-02-17 23:08:33 +07:00
Mohamed Boudra
0354c409f1 fix(worktree): hash cwd for collision-safe worktree roots 2026-02-17 23:07:20 +07:00
Mohamed Boudra
6aa5e97d75 feat: make slash commands available in draft agent screen 2026-02-17 23:06:37 +07:00
Mohamed Boudra
8c0d289c2a fix(app): refit terminal when returning to agent 2026-02-17 23:03:11 +07:00
Mohamed Boudra
a5842482b0 chore(release): cut 0.1.13 2026-02-17 21:39:24 +07:00
Mohamed Boudra
9b9535aa78 ci: publish Android APK to GitHub releases 2026-02-17 21:36:15 +07:00
Mohamed Boudra
515cb0a777 refactor: remove legacy agent update RPCs 2026-02-17 21:16:31 +07:00
Mohamed Boudra
0dc944df3a fix(server): pin node-pty beta with macOS spawn-helper fix 2026-02-17 21:07:11 +07:00
Mohamed Boudra
16ea92aec5 fix(app): stop mobile web sidebars from intercepting taps when closed 2026-02-17 20:05:20 +07:00
Mohamed Boudra
93ec537095 chore(release): cut 0.1.12 2026-02-17 17:17:21 +07:00
Mohamed Boudra
9255212043 fix(server): self-heal node-pty spawn-helper execute bit 2026-02-17 17:16:47 +07:00
Mohamed Boudra
a03da6774a chore(release): cut 0.1.11 2026-02-17 16:22:01 +07:00
Mohamed Boudra
7c5bbc8048 chore(release): cut 0.1.10 2026-02-17 16:19:52 +07:00
Mohamed Boudra
76e6ad89d2 fix(server): publish daemon-runner sherpa runtime module 2026-02-17 16:19:38 +07:00
Mohamed Boudra
93d6eb611e chore(release): cut 0.1.9 2026-02-17 14:53:22 +07:00
Mohamed Boudra
ce5d9d6dc8 refactor: unify structured generation pipeline 2026-02-17 14:52:58 +07:00
Mohamed Boudra
abc146b186 chore(release): cut 0.1.8 2026-02-17 13:45:15 +07:00
Mohamed Boudra
4300750001 chore(release): cut 0.1.8 2026-02-17 13:32:13 +07:00
Mohamed Boudra
0af2d8989a fix(web): route tool-detail wheel events at scroll edges 2026-02-17 13:20:49 +07:00
Mohamed Boudra
34e35621f0 simplify speech bootstrap and clean daemon startup locking 2026-02-17 12:16:02 +07:00
Mohamed Boudra
efde557d6f Fix stuck send-while-running processing recovery 2026-02-17 10:43:20 +07:00
Mohamed Boudra
50b99584a1 fix(app): update combobox options behavior and tests 2026-02-17 10:38:31 +07:00
Mohamed Boudra
459af22670 Add cross-platform confirm dialog wrapper for daemon restart 2026-02-17 10:04:58 +07:00
Mohamed Boudra
12ba65d6e7 fix(app): guard tauri listener cleanup to prevent uncaught unlisten errors 2026-02-17 09:43:22 +07:00
Mohamed Boudra
bb6d30e4f8 fix(server): preserve claude session identity on agent reload 2026-02-17 09:43:19 +07:00
Mohamed Boudra
82a5723240 docs: add changelog for releases 0.1.1 through 0.1.7 2026-02-17 09:00:26 +07:00
Mohamed Boudra
949ac8decb website: update hero copy to on your machine 2026-02-17 00:20:40 +07:00
Mohamed Boudra
de99646880 chore(release): cut 0.1.7 2026-02-16 23:19:16 +07:00
Mohamed Boudra
170ee27813 feat: improve agent workspace flows and directory suggestions 2026-02-16 23:19:01 +07:00
Mohamed Boudra
22da0951d5 website: shorten iOS and Android CTA labels 2026-02-16 22:47:54 +07:00
Mohamed Boudra
c371dc92a5 Wire Firebase services files per app variant 2026-02-16 21:21:04 +07:00
Mohamed Boudra
427e3daca9 fix(app): prevent desktop combobox initial 0,0 flash 2026-02-16 18:37:04 +07:00
Mohamed Boudra
f590e60617 Remove uppercase text transform from settings label 2026-02-16 18:17:34 +07:00
Mohamed Boudra
259209b7ef feat: add iOS TestFlight and Android request forms to website 2026-02-16 18:11:25 +07:00
Mohamed Boudra
93111b47ae chore(release): auto-increment android versionCode and fix CLI version output 2026-02-16 12:22:32 +07:00
Mohamed Boudra
f8f4c55976 Show image previews in optimistic user messages 2026-02-16 12:19:38 +07:00
Mohamed Boudra
90a81462cb fix worktree archive terminal cleanup and worktree setup 2026-02-16 11:55:16 +07:00
Mohamed Boudra
7008803800 docs(website): add updates guide for daemon and apps 2026-02-16 11:06:04 +07:00
Mohamed Boudra
52b3001111 docs: simplify root readme for end-user quick start 2026-02-16 10:58:28 +07:00
Mohamed Boudra
6c7ce63272 chore(website): generate Mac download link from package version 2026-02-16 10:55:54 +07:00
Mohamed Boudra
04a06197de refactor(startup): unify dev and cli daemon startup through runner 2026-02-16 03:22:03 +00:00
Mohamed Boudra
5bd6cc11de fix(server): harden sherpa native loading and centralize runtime env 2026-02-16 02:59:12 +00:00
Mohamed Boudra
4313e3623b chore: update desktop download link and remove stray file 2026-02-16 09:15:29 +07:00
378 changed files with 45274 additions and 12141 deletions

View File

@@ -0,0 +1,119 @@
name: Android APK Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
concurrency:
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
cancel-in-progress: false
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android APK on EAS
id: eas_build
shell: bash
run: |
set -euo pipefail
cd packages/app
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
if [ -z "$build_id" ]; then
echo "Failed to determine EAS build ID."
cat "$RUNNER_TEMP/eas-build.json"
exit 1
fi
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
- name: Resolve APK artifact URL
id: artifact
shell: bash
run: |
set -euo pipefail
cd packages/app
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
if [ -z "$artifact_url" ]; then
echo "Failed to determine APK artifact URL."
cat "$RUNNER_TEMP/eas-build-view.json"
exit 1
fi
asset_name="paseo-${RELEASE_TAG}-android.apk"
asset_path="$RUNNER_TEMP/$asset_name"
curl --fail --location --output "$asset_path" "$artifact_url"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"

View File

@@ -2,11 +2,8 @@ name: Deploy App
on:
push:
branches: [main]
paths:
- 'packages/app/**'
- 'packages/server/src/**'
- '.github/workflows/deploy-app.yml'
tags:
- 'v*'
workflow_dispatch:
jobs:

View File

@@ -106,7 +106,7 @@ jobs:
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo Desktop ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false

View File

@@ -0,0 +1,67 @@
name: Release Notes Sync
on:
push:
tags:
- "v*"
branches:
- main
paths:
- "CHANGELOG.md"
workflow_dispatch:
inputs:
tag:
description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry."
required: false
type: string
create_if_missing:
description: "Create release if missing (normally only needed for tag events)."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
sync-release-notes:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sync release body from changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
shell: bash
run: |
set -euo pipefail
args=(--repo "$REPO")
if [ -n "${INPUT_TAG:-}" ]; then
args+=(--tag "$INPUT_TAG")
fi
create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi
if [ "$create_if_missing" = "true" ]; then
args+=(--create-if-missing)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"

162
CHANGELOG.md Normal file
View File

@@ -0,0 +1,162 @@
# Changelog
## 0.1.16 - 2026-02-22
### Added
- Added desktop Settings controls to update both the Paseo app and the local daemon.
- Added support for provider thinking options and autonomous run handling.
### Improved
- Improved streaming performance and scroll stability to reduce UI churn during long runs.
- Improved archive behavior by interrupting active agents before archive operations.
- Improved agent title consistency across refreshes.
- Improved desktop update/settings copy and labeling for clarity.
### Fixed
- Fixed duplicate user-message echoes by deduplicating with canonical message IDs.
- Fixed app archive flow instability caused by hook-order issues.
- Fixed startup crashes caused by the OpenCode SDK update.
- Fixed noisy attention notifications from non-UI agent activity.
## 0.1.15 - 2026-02-19
### Added
- Added a public changelog page on the website so users can browse release notes.
### Improved
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
- Hid empty filter groups in the left sidebar.
### Fixed
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
- Fixed duplicate `/rewind` user-message behavior.
## 0.1.14 - 2026-02-19
### Added
- Added Claude `/rewind` command support.
- Added slash command access in the draft agent composer.
- Added `@` workspace file autocomplete in chat prompts.
- Added support for pasting images directly into prompt attachments.
- Added optimistic image previews for pending user message attachments.
- Added shared desktop/web overlay scroll handles, including file preview panes.
### Improved
- Improved worktree flow after shipping, including better merged PR detection.
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions.
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
- Improved desktop settings and permissions UX in Tauri.
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
- Fixed terminal sizing when switching back to an agent session.
- Fixed accidental terminal closure risk by adding confirmation for running shell commands.
- Fixed archive loading-state consistency across the sidebar and agent screen.
- Fixed autocomplete popover stability and workspace suggestion ranking.
- Fixed dictation timeouts caused by dangling non-final segments.
- Fixed server lock ownership when spawned as a child process by using parent PID ownership.
- Fixed hidden directory leakage in server CWD suggestions.
- Fixed agent attention notification payload consistency across providers.
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
## 0.1.9 - 2026-02-17
### Improved
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
- Reused provider availability checks for structured generation fallback selection.
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
### Fixed
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
## 0.1.8 - 2026-02-17
### Added
- Added a cross-platform confirm dialog flow for daemon restarts.
### Improved
- Simplified local speech bootstrap and daemon startup locking behavior.
- Updated website hero copy to emphasize local execution.
### Fixed
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16
### Added
- Improved agent workspace flows with better directory suggestions.
- Added iOS TestFlight and Android app access request forms on the website.
### Improved
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
- Improved website app download and update guidance.
### Fixed
- Prevented an initial desktop combobox `0,0` position flash.
- Fixed CLI version output issues.
- Hardened server runtime loading for local speech dependencies.
## 0.1.6 - 2026-02-16
### Notes
- No major visible product changes in this patch release.
## 0.1.5 - 2026-02-16
### Added
- Added terminal reattach support and better worktree terminal handling.
- Added global keyboard shortcut help in the app.
- Added sidebar host filtering and improved agent workflow controls.
### Improved
- Improved worktree setup visibility by streaming setup progress.
- Improved terminal streaming reliability and lifecycle handling.
- Preserved explorer tab state so context survives navigation better.
## 0.1.4 - 2026-02-14
### Added
- Added voice capability status reporting in the client.
- Added background local speech model downloads with runtime gating.
- Added adaptive dictation finish timing based on server-provided budgets.
- Added relay reconnect behavior with grace periods and branch suggestions.
### Improved
- Improved connection selection and agent hydration reliability.
- Improved timeline loading with cursor-based fetch behavior.
- Improved first-run experience by bootstrapping a default localhost connection.
- Improved inline code rendering by auto-linkifying URLs.
### Fixed
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
- Fixed stale relay client timer behavior.
- Fixed unnecessary git diff header auto-scroll on collapse.
## 0.1.3 - 2026-02-12
### Added
- Added CLI onboarding command.
- Added CLI `--output-schema` support for structured agent output.
- Added CLI agent metadata update support for names and labels.
- Added provider availability detection with normalization of legacy default model IDs.
### Improved
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
- Added better voice interrupt handling with a speech-start grace period.
- Improved CLI defaults to list all non-archived agents by default.
- Improved website UX with clearer install CTA and privacy policy access.
### Fixed
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
## 0.1.2 - 2026-02-11
### Notes
- No major visible product changes in this patch release.
## 0.1.1 - 2026-02-11
### Added
- Initial `0.1.x` release line.

View File

@@ -56,6 +56,26 @@ Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` w
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
### Relay build sync (important)
When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`.
### Server build sync for CLI (important)
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts.
### Quick reference CLI commands
```bash
@@ -139,6 +159,7 @@ npm run android:production
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
That workflow does:
- Build iOS with the `production` profile
@@ -182,7 +203,7 @@ npm run release:patch
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
```
Notes:
@@ -191,11 +212,16 @@ Notes:
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
Release completion checklist:
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed. The notes are user-facing:
- Ask yourself, what do Paseo users want to know about?
- Include: New features, bug fixes
- Don't include: Refactors or code changes that are not noticeable by users
- `npm run release:patch` completes successfully.
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
- GitHub `Android APK Release` workflow for the same tag is green.
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
## Orchestrator Mode

View File

@@ -17,91 +17,42 @@
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
## Features
- **Self-hosted:** The daemon runs on your laptop, home server, or VPS
- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface
- **Multi-host:** Connect to multiple daemons and see all your agents in one place
- **Voice input:** Dictate prompts when you're away from your keyboard
- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly
- **Cross-device:** iOS, Android, desktop, web, and CLI
- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app
- **Open source:** Free and open source under MIT license
## Quick Start
## Getting Started
```bash
npm install -g @getpaseo/cli && paseo
npm install -g @getpaseo/cli
paseo
```
Then open the app and connect to your daemon.
## Local speech (STT/TTS)
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`.
## Development
When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`).
Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Tauri desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)
Common commands:
```bash
npm run speech:download --workspace=@getpaseo/server
```
# run all local dev services
npm run dev
Optional configuration:
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:website
- `PASEO_SHERPA_ONNX_MODELS_DIR` (defaults to `~/.paseo/models/sherpa-onnx`)
- `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD` (`1` by default; set `0` to disable automatic downloads on daemon start)
- `PASEO_SHERPA_STT_PRESET` (`zipformer`, `paraformer`, or `parakeet` for NVIDIA Parakeet TDT v3)
- `PASEO_SHERPA_TTS_PRESET` (`pocket-tts` (Kyutai Pocket TTS), `kitten`, or `kokoro`)
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER` (`sherpa` or `openai`)
To see all supported local model IDs:
```bash
npm run speech:models --workspace=@getpaseo/server
```
Optional: run an end-to-end test that downloads real models and exercises streaming STT + streaming TTS:
```bash
PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \
npx vitest run --workspace=@getpaseo/server src/server/speech/sherpa/speech-download.e2e.test.ts
```
## Documentation
See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
## Releases
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
```bash
npm run version:all:patch
npm run release:push
```
For the full package release flow, use:
```bash
npm run release:patch
```
`npm run release:patch` bumps all workspace versions together, publishes npm packages (`@getpaseo/relay`, `@getpaseo/server`, `@getpaseo/cli`), and pushes the matching `v*` tag.
The tag triggers:
- GitHub `Desktop Release` workflow (`.github/workflows/desktop-release.yml`)
- Expo EAS mobile workflow (`packages/app/.eas/workflows/release-mobile.yml`) to build + submit Android/iOS
Useful monitoring commands after a release push:
```bash
# Desktop (GitHub Actions)
gh run list --workflow "Desktop Release" --limit 10
gh run watch <run-id>
# Mobile (EAS Workflows)
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
cd packages/app && npx eas workflow:view <run-id>
# repo-wide checks
npm run typecheck
```
## License

5
app.json Normal file
View File

@@ -0,0 +1,5 @@
{
"android": {
"package": "com.moboudra.paseo"
}
}

1242
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.6",
"version": "0.1.16",
"private": true,
"workspaces": [
"packages/server",
@@ -12,7 +12,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
@@ -30,7 +30,7 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",

View File

@@ -1,14 +1,46 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const appVariant = process.env.APP_VARIANT ?? "production";
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
return fromEnv.trim();
}
const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath);
if (fs.existsSync(fallbackAbsolutePath)) {
return params.fallbackRelativePath;
}
return undefined;
}
const variants = {
production: {
name: "Paseo",
packageId: "sh.paseo",
googleServicesFile: resolveSecretFile({
envKey: "GOOGLE_SERVICES_FILE_PROD",
fallbackRelativePath: "./.secrets/google-services.prod.json",
}),
googleServiceInfoPlist: resolveSecretFile({
envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD",
fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist",
}),
},
development: {
name: "Paseo Debug",
packageId: "sh.paseo.debug",
googleServicesFile: resolveSecretFile({
envKey: "GOOGLE_SERVICES_FILE_DEBUG",
fallbackRelativePath: "./.secrets/google-services.debug.json",
}),
googleServiceInfoPlist: resolveSecretFile({
envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG",
fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist",
}),
},
};
@@ -38,6 +70,9 @@ export default {
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
},
android: {
adaptiveIcon: {
@@ -57,6 +92,9 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile
? { googleServicesFile: variant.googleServicesFile }
: {}),
},
web: {
output: "single",

View File

@@ -394,7 +394,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
await page.getByTestId('changes-menu-archive-worktree').click();
// Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Repo inspection is async; wait until git options are interactive again.

View File

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

View File

@@ -156,7 +156,6 @@ export default async function globalSetup() {
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -162,9 +162,16 @@ export const gotoHome = async (page: Page) => {
};
export const openSettings = async (page: Page) => {
// Navigate directly to settings page
await page.goto('/settings');
await expect(page).toHaveURL(/\/settings$/);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
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.
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
@@ -173,7 +180,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const input = page.getByRole('textbox', { name: '/path/to/project' });
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 });
const closeBottomSheet = async () => {
@@ -218,29 +226,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeWorktreeSheetIfOpen();
if (!(await input.isVisible())) {
const pickerInputVisible = async () =>
(await directorySearchInput.isVisible().catch(() => false)) ||
(await legacyInput.isVisible().catch(() => false));
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
if (!(await input.isVisible())) {
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect(input).toBeVisible();
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
await input.fill(trimmedDirectory);
await input.press('Enter');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
const useOption = page.getByTestId('working-directory-custom-option').first();
if (await useOption.isVisible().catch(() => false)) {
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
await useOption.click({ force: true });
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'))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.first();
if (await plainOption.isVisible().catch(() => false)) {
await plainOption.click({ force: true });
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter');
}
} else {
// Legacy path picker fallback.
await activeInput.press('Enter');
}
// Wait for the sheet to close after clicking "Use"
await expect(input).not.toBeVisible({ timeout: 10000 });
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {

View File

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

View File

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

View File

@@ -395,6 +395,86 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
}
});
test("closing terminal with running command asks for confirmation", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-close-confirm-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal close confirmation");
await openTerminalsPanel(page);
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first();
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-${Date.now()}`;
await runTerminalCommand(
page,
`echo ${runningMarker} && sleep 30`,
runningMarker
);
await tab.hover();
const dialogPromise = page.waitForEvent("dialog", { timeout: 30000 }).then(
async (dialog) => {
expect(dialog.type()).toBe("confirm");
await dialog.dismiss();
}
);
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
await dialogPromise;
await expect(tab).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("confirming terminal close with running command removes the tab", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-close-confirm-accept-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal close confirmation accept");
await openTerminalsPanel(page);
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first();
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
await runTerminalCommand(
page,
`echo ${runningMarker} && sleep 30`,
runningMarker
);
await tab.hover();
const dialogPromise = page.waitForEvent("dialog", { timeout: 30000 }).then(
async (dialog) => {
expect(dialog.type()).toBe("confirm");
await dialog.accept();
}
);
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
await dialogPromise;
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");

View File

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

View File

@@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 14.0.0"
"version": ">= 14.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
@@ -18,6 +19,9 @@
"channel": "production",
"env": {
"APP_VARIANT": "production"
},
"android": {
"autoIncrement": "versionCode"
}
},
"production-apk": {
@@ -33,6 +37,9 @@
"production": {
"ios": {
"ascAppId": "6758887924"
},
"android": {
"releaseStatus": "draft"
}
}
}

View File

@@ -7,6 +7,10 @@ const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.trim()
.replace(/^\./, "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
@@ -25,6 +29,40 @@ config.resolver.nodeModulesPaths = Array.from(
])
);
function isLocalModuleImport(moduleName) {
return (
moduleName.startsWith("./") ||
moduleName.startsWith("../") ||
moduleName.startsWith("@/") ||
path.isAbsolute(moduleName)
);
}
function resolveWithCustomWebOverlay(context, moduleName, platform) {
const shouldResolveCustomWebVariant =
platform === "web" &&
customWebPlatform.length > 0 &&
customWebPlatform !== "web" &&
isLocalModuleImport(moduleName);
if (shouldResolveCustomWebVariant) {
const overlayContext = {
...context,
// Resolve only "<custom-platform>.<ext>" variants in overlay mode.
sourceExts: context.sourceExts.map((ext) => `${customWebPlatform}.${ext}`),
preferNativePlatform: false,
};
try {
return defaultResolveRequest(overlayContext, moduleName, null);
} catch {
// Ignore overlay misses and continue with normal web resolution.
}
}
return defaultResolveRequest(context, moduleName, platform);
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (
@@ -35,11 +73,11 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
return defaultResolveRequest(context, tsModuleName, platform);
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
}
}
return defaultResolveRequest(context, moduleName, platform);
return resolveWithCustomWebOverlay(context, moduleName, platform);
};
module.exports = config;

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.6",
"version": "0.1.16",
"private": true,
"scripts": {
"start": "expo start",
@@ -14,6 +14,7 @@
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -21,6 +22,7 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
},
"dependencies": {
@@ -30,7 +32,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.6",
"@getpaseo/server": "0.1.16",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -77,6 +79,7 @@
"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.0",

View File

@@ -20,6 +20,22 @@ const webEcosystemStyles = /* css */ `
-webkit-user-select: text;
user-select: text;
}
[data-testid="sidebar-agent-list-scroll"],
[data-testid="agent-chat-scroll"],
[data-testid="git-diff-scroll"],
[data-testid="file-explorer-tree-scroll"] {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
function WebRespectfulStyleReset() {

View File

@@ -13,14 +13,13 @@ import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { SlidingSidebar } from "@/components/sliding-sidebar";
import { LeftSidebar } from "@/components/left-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
@@ -42,14 +41,28 @@ import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
polyfillCrypto();
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
}
function PushNotificationRouter() {
const router = useRouter();
@@ -57,6 +70,15 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
@@ -201,6 +223,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
@@ -217,6 +243,13 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
@@ -235,6 +268,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
animateToOpen,
animateToClose,
openAgentList,
mobileView,
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
@@ -248,12 +282,12 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const content = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<KeyboardShortcutsDialog />
@@ -409,13 +443,13 @@ function MissingDaemonView() {
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<DaemonConnectionsProvider>
<PerfDiagnosticsProvider scope="root_layout">
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
@@ -445,13 +479,13 @@ export default function RootLayout() {
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonConnectionsProvider>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</PerfDiagnosticsProvider>
</GestureHandlerRootView>
);
}

View File

@@ -0,0 +1,208 @@
import * as FileSystem from "expo-file-system/legacy";
import {
type AttachmentStore,
type AttachmentStorageType,
type AttachmentMetadata,
type SaveAttachmentInput,
} from "@/attachments/types";
import {
blobToBase64,
fileUriToPath,
generateAttachmentId,
getFileExtensionFromName,
normalizeMimeType,
parseDataUrl,
pathToFileUri,
} from "@/attachments/utils";
const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/avif": ".avif",
"image/heic": ".heic",
"image/heif": ".heif",
"image/tiff": ".tiff",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
};
function extensionForAttachment(params: { fileName?: string | null; mimeType: string }): string {
const fromName = getFileExtensionFromName(params.fileName);
if (fromName) {
return fromName;
}
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
}
async function ensureDirectory(uri: string): Promise<void> {
const info = await FileSystem.getInfoAsync(uri);
if (info.exists && info.isDirectory) {
return;
}
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
async function writeFromSource(input: {
source: SaveAttachmentInput["source"];
targetUri: string;
mimeType: string;
}): Promise<void> {
if (input.source.kind === "file_uri") {
const from = pathToFileUri(input.source.uri);
if (from === input.targetUri) {
return;
}
await FileSystem.copyAsync({ from, to: input.targetUri });
return;
}
if (input.source.kind === "data_url") {
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
const base64 = parsed.base64;
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (mimeType !== input.mimeType) {
return;
}
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
}
function attachmentUri(metadata: AttachmentMetadata): string {
return pathToFileUri(metadata.storageKey);
}
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>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
: null;
async function resolveTarget(input: SaveAttachmentInput): Promise<{
id: string;
mimeType: string;
fileName: string | null;
createdAt: number;
targetUri: string;
storageKey: string;
}> {
if (!baseDirectory) {
throw new Error("expo-file-system cacheDirectory is unavailable.");
}
await ensureDirectory(baseDirectory);
const id = input.id ?? generateAttachmentId();
const mimeTypeFromSource =
input.source.kind === "data_url"
? parseDataUrl(input.source.dataUrl).mimeType
: input.source.kind === "blob"
? input.source.blob.type
: undefined;
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
const fileName = input.fileName ?? null;
const extension = extensionForAttachment({ fileName, mimeType });
const createdAt = Date.now();
const targetUri = `${baseDirectory}${id}${extension}`;
const storageKey = fileUriToPath(targetUri);
return {
id,
mimeType,
fileName,
createdAt,
targetUri,
storageKey,
};
}
return {
storageType: params.storageType,
async save(input): Promise<AttachmentMetadata> {
const target = await resolveTarget(input);
await writeFromSource({
source: input.source,
targetUri: target.targetUri,
mimeType: target.mimeType,
});
const info = await FileSystem.getInfoAsync(target.targetUri);
const byteSize =
info.exists && typeof (info as { size?: number }).size === "number"
? (info as { size: number }).size
: null;
return {
id: target.id,
mimeType: target.mimeType,
storageType: params.storageType,
storageKey: target.storageKey,
fileName: target.fileName,
byteSize,
createdAt: target.createdAt,
};
},
async encodeBase64({ attachment }): Promise<string> {
const uri = attachmentUri(attachment);
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
},
async resolvePreviewUrl({ attachment }): Promise<string> {
return await params.resolvePreviewUrl(attachment);
},
...(params.releasePreviewUrl
? {
async releasePreviewUrl(input: {
attachment: AttachmentMetadata;
url: string;
}): Promise<void> {
await params.releasePreviewUrl?.(input);
},
}
: {}),
async delete({ attachment }): Promise<void> {
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise<void> {
if (!baseDirectory) {
return;
}
await ensureDirectory(baseDirectory);
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
await Promise.all(
entries.map(async (entryName) => {
const id = entryName.split(".", 1)[0] ?? "";
if (!id || referencedIds.has(id)) {
return;
}
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
})
);
},
};
}

View File

@@ -0,0 +1,17 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "paseo-native-attachments",
resolvePreviewUrl: async (attachment) => {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
return attachment.storageKey;
},
});
}

View File

@@ -0,0 +1,125 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { getAttachmentStore } from "@/attachments/store";
export async function persistAttachmentFromBlob(input: {
blob: Blob;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "blob", blob: input.blob },
});
}
export async function persistAttachmentFromDataUrl(input: {
dataUrl: string;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "data_url", dataUrl: input.dataUrl },
});
}
export async function persistAttachmentFromFileUri(input: {
uri: string;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "file_uri", uri: input.uri },
});
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
}
const store = await getAttachmentStore();
const encoded = await Promise.all(
attachments.map(async (attachment) => {
try {
const data = await store.encodeBase64({ attachment });
return {
data,
mimeType: attachment.mimeType,
};
} catch (error) {
console.error("[attachments] Failed to encode attachment for send", {
id: attachment.id,
error,
});
return null;
}
})
);
const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
);
return valid.length > 0 ? valid : undefined;
}
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
export async function releaseAttachmentPreviewUrl(input: {
attachment: AttachmentMetadata;
url: string;
}): Promise<void> {
const store = await getAttachmentStore();
if (!store.releasePreviewUrl) {
return;
}
await store.releasePreviewUrl({ attachment: input.attachment, url: input.url });
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
}
const store = await getAttachmentStore();
await Promise.all(
attachments.map(async (attachment) => {
try {
await store.delete({ attachment });
} catch (error) {
console.warn("[attachments] Failed to delete attachment", {
id: attachment.id,
error,
});
}
})
);
}
export async function garbageCollectAttachments(input: {
referencedIds: ReadonlySet<string>;
}): Promise<void> {
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds: input.referencedIds });
}

View File

@@ -0,0 +1,38 @@
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
const { createDesktopAttachmentStore } = await import(
"@/desktop/attachments/desktop-attachment-store"
);
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import(
"@/attachments/web/indexeddb-attachment-store"
);
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import(
"@/attachments/native/native-file-attachment-store"
);
return createNativeFileAttachmentStore();
}
export async function getAttachmentStore(): Promise<AttachmentStore> {
if (!attachmentStorePromise) {
attachmentStorePromise = createAttachmentStore();
}
return await attachmentStorePromise;
}
/** Test-only hook to inject a deterministic store implementation. */
export function __setAttachmentStoreForTests(store: AttachmentStore | null): void {
attachmentStorePromise = store ? Promise.resolve(store) : null;
}

View File

@@ -0,0 +1,63 @@
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
export interface AttachmentMetadata {
id: string;
mimeType: string;
storageType: AttachmentStorageType;
/**
* Platform-specific location key.
* - web-indexeddb: object store key
* - desktop-file/native-file: absolute file path without preview URL indirection
*/
storageKey: string;
fileName?: string | null;
byteSize?: number | null;
createdAt: number;
}
export type AttachmentDataSource =
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
export interface SaveAttachmentInput {
id?: string;
mimeType?: string;
fileName?: string | null;
source: AttachmentDataSource;
}
export interface ResolvePreviewUrlInput {
attachment: AttachmentMetadata;
}
export interface ReleasePreviewUrlInput {
attachment: AttachmentMetadata;
url: string;
}
export interface EncodeAttachmentInput {
attachment: AttachmentMetadata;
}
export interface DeleteAttachmentInput {
attachment: AttachmentMetadata;
}
export interface GarbageCollectInput {
referencedIds: ReadonlySet<string>;
}
/**
* Async storage contract for attachment bytes.
* Metadata is persisted in drafts/messages; bytes live in platform stores.
*/
export interface AttachmentStore {
readonly storageType: AttachmentStorageType;
save(input: SaveAttachmentInput): Promise<AttachmentMetadata>;
encodeBase64(input: EncodeAttachmentInput): Promise<string>;
resolvePreviewUrl(input: ResolvePreviewUrlInput): Promise<string>;
releasePreviewUrl?(input: ReleasePreviewUrlInput): Promise<void>;
delete(input: DeleteAttachmentInput): Promise<void>;
garbageCollect(input: GarbageCollectInput): Promise<void>;
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
useEffect(() => {
let disposed = false;
let currentUrl: string | null = null;
activeAttachmentRef.current = attachment ?? null;
if (!attachment) {
setUrl(null);
return;
}
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(attachment);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
return;
}
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: attachment.id,
error,
});
if (!disposed) {
setUrl(null);
}
}
})();
return () => {
disposed = true;
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
}
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
};
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
return url;
}

View File

@@ -0,0 +1,78 @@
import { generateMessageId } from "@/types/stream";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
}
export function normalizeMimeType(input: string | undefined | null): string {
if (!input) {
return "image/jpeg";
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : "image/jpeg";
}
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
if (!match) {
throw new Error("Malformed data URL for attachment.");
}
const [, mimeTypeRaw, base64] = match;
if (!base64) {
throw new Error("Attachment data URL is missing base64 payload.");
}
return {
mimeType: normalizeMimeType(mimeTypeRaw),
base64,
};
}
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result while encoding attachment."));
return;
}
const payload = reader.result.split(",", 2)[1];
if (!payload) {
reject(new Error("Attachment FileReader result did not contain base64 payload."));
return;
}
resolve(payload);
};
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read attachment blob."));
};
reader.readAsDataURL(blob);
});
}
export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
return path;
}
export function fileUriToPath(uri: string): string {
if (!uri.startsWith("file://")) {
return uri;
}
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
}
export function getFileExtensionFromName(fileName: string | null | undefined): string {
if (!fileName) {
return "";
}
const idx = fileName.lastIndexOf(".");
if (idx <= 0 || idx === fileName.length - 1) {
return "";
}
return fileName.slice(idx);
}

View File

@@ -0,0 +1,213 @@
import {
type AttachmentStore,
type AttachmentMetadata,
type SaveAttachmentInput,
} from "@/attachments/types";
import {
blobToBase64,
generateAttachmentId,
normalizeMimeType,
parseDataUrl,
} from "@/attachments/utils";
type StoredBlobRecord = {
id: string;
blob: Blob;
createdAt: number;
fileName: string | null;
};
const DB_NAME = "paseo-attachment-bytes";
const STORE_NAME = "attachments";
const DB_VERSION = 1;
function ensureIndexedDb(): IDBFactory {
const idb = globalThis.indexedDB;
if (!idb) {
throw new Error("IndexedDB is unavailable in this runtime.");
}
return idb;
}
function openAttachmentDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = ensureIndexedDb().open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error ?? new Error("Failed to open attachment IndexedDB."));
};
});
}
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
const store = transaction.objectStore(STORE_NAME);
const request = run(store);
request.onsuccess = () => {
resolve(request.result as T);
};
request.onerror = () => {
reject(request.error ?? new Error("IndexedDB transaction request failed."));
};
transaction.onerror = () => {
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
};
});
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
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);
return { blob, mimeType };
}
if (source.kind === "data_url") {
const parsed = parseDataUrl(source.dataUrl);
const response = await fetch(source.dataUrl);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType ?? blob.type);
return {
blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
return {
blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType),
mimeType,
};
}
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id)
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
}
return record.blob;
}
export function createIndexedDbAttachmentStore(): AttachmentStore {
return {
storageType: "web-indexeddb",
async save(input): Promise<AttachmentMetadata> {
const id = input.id ?? generateAttachmentId();
const createdAt = Date.now();
const { blob, mimeType } = await sourceToBlob(input);
const fileName = input.fileName ?? null;
const db = await openAttachmentDb();
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
);
} finally {
db.close();
}
return {
id,
mimeType,
storageType: "web-indexeddb",
storageKey: id,
fileName,
byteSize: blob.size,
createdAt,
};
},
async encodeBase64({ attachment }): Promise<string> {
const db = await openAttachmentDb();
try {
const blob = await loadBlob(db, attachment.storageKey);
return await blobToBase64(blob);
} finally {
db.close();
}
},
async resolvePreviewUrl({ attachment }): Promise<string> {
const db = await openAttachmentDb();
try {
const blob = await loadBlob(db, attachment.storageKey);
return URL.createObjectURL(blob);
} finally {
db.close();
}
},
async releasePreviewUrl({ url }): Promise<void> {
URL.revokeObjectURL(url);
},
async delete({ attachment }): Promise<void> {
const db = await openAttachmentDb();
try {
await runTx(db, "readwrite", (store) => store.delete(attachment.storageKey));
} finally {
db.close();
}
},
async garbageCollect({ referencedIds }): Promise<void> {
const db = await openAttachmentDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
};
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) {
resolve();
return;
}
const key = String(cursor.key);
if (!referencedIds.has(key)) {
cursor.delete();
}
cursor.continue();
};
tx.onerror = () => {
reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments."));
};
});
} finally {
db.close();
}
},
};
}

View File

@@ -97,7 +97,7 @@ export function DropdownField({
>
{value || placeholder}
</Text>
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
<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}
@@ -196,7 +196,7 @@ export function SelectField({
{value || placeholder || "Select..."}
</Text>
</View>
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} />
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -289,7 +289,7 @@ export function DropdownSheet({
hitSlop={10}
testID="dropdown-sheet-close"
>
<X size={18} color={defaultTheme.colors.foregroundMuted} />
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
@@ -477,7 +477,7 @@ export function FormSelectTrigger({
</Text>
)}
</View>
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
);
}
@@ -571,7 +571,7 @@ export function AgentConfigRow({
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider}
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -585,7 +585,7 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -598,7 +598,7 @@ export function AgentConfigRow({
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
icon={<Shield size={16} color={defaultTheme.colors.foregroundMuted} />}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -612,7 +612,7 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -862,16 +862,14 @@ export function WorkingDirectoryDropdown({
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const emptyText = suggestedPaths.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist.";
const emptyText = "No agent directories match your search.";
return (
<>
<SelectField
label="WORKING DIRECTORY"
value={workingDir}
placeholder="/path/to/project"
placeholder="Choose a working directory"
onPress={handleOpen}
disabled={disabled}
errorMessage={errorMessage || undefined}
@@ -883,11 +881,12 @@ export function WorkingDirectoryDropdown({
options={options}
value={workingDir}
onSelect={onSelectPath}
searchPlaceholder="/path/to/project"
searchPlaceholder="Search directories..."
emptyText={emptyText}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isOpen}
onOpenChange={handleOpenChange}
@@ -1119,16 +1118,16 @@ export function GitOptionsSection({
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={16} color={defaultTheme.colors.foregroundMuted} />
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} />
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
)}
</View>

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import {
type SectionListRenderItem,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQueryClient } from "@tanstack/react-query";
@@ -18,6 +18,10 @@ import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
} from "@/runtime/host-runtime";
import { AgentStatusDot } from "@/components/agent-status-dot";
import {
CHECKOUT_STATUS_STALE_TIME,
@@ -77,6 +81,71 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
return "Older";
}
interface AgentListRowProps {
agent: AggregatedAgent;
selectedAgentId?: string;
showCheckoutInfo: boolean;
onPress: (serverId: string, agentId: string) => void;
onLongPress: (agent: AggregatedAgent) => void;
}
function AgentListRow({
agent,
selectedAgentId,
showCheckoutInfo,
onPress,
onLongPress,
}: AgentListRowProps) {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const projectPath = showCheckoutInfo
? deriveProjectPath(agent.cwd, checkout)
: agent.cwd;
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
return (
<Pressable
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => onPress(agent.serverId, agent.id)}
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
</Text>
</View>
)}
</Pressable>
);
}
export function AgentList({
agents,
showCheckoutInfo = true,
@@ -105,12 +174,6 @@ export function AgentList({
return;
}
// Clear attention flag when opening agent
const session = useSessionStore.getState().sessions[serverId];
if (session?.client) {
session.client.clearAgentAttention(agentId);
}
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
@@ -160,9 +223,9 @@ export function AgentList({
continue;
}
const session = useSessionStore.getState().sessions[agent.serverId];
const client = session?.client ?? null;
const isConnected = session?.connection.isConnected ?? false;
const runtime = getHostRuntimeStore();
const client = runtime.getClient(agent.serverId);
const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId));
if (!client || !isConnected) {
continue;
}
@@ -189,66 +252,6 @@ export function AgentList({
[queryClient, showCheckoutInfo]
);
const AgentListRow = useCallback(
({ agent }: { agent: AggregatedAgent }) => {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const projectPath = showCheckoutInfo
? deriveProjectPath(agent.cwd, checkout)
: agent.cwd;
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
return (
<Pressable
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
</Text>
</View>
)}
</Pressable>
);
},
[
handleAgentLongPress,
handleAgentPress,
selectedAgentId,
showCheckoutInfo,
]
);
const sections = useMemo((): AgentListSection[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
@@ -271,7 +274,18 @@ export function AgentList({
}, [agents]);
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> =
useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]);
useCallback(
({ item: agent }) => (
<AgentListRow
agent={agent}
selectedAgentId={selectedAgentId}
showCheckoutInfo={showCheckoutInfo}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
),
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
);
const renderSectionHeader = useCallback(
({ section }: { section: AgentListSection }) => (

View File

@@ -38,17 +38,19 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
if (!agent) {
return null;
}
const canFetchModels = Boolean(client) && Boolean(agent.provider) && (IS_WEB || prefsOpen);
const canFetchModels =
Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent.provider, agent.cwd],
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: canFetchModels,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
@@ -61,7 +63,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const models = modelsQuery.data ?? null;
function handleModeChange(modeId: string) {
if (!client) {
if (!client || !agent) {
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
@@ -69,8 +71,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
});
}
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent.model);
const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent?.model);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = useMemo(() => {
if (!models || !preferredModelId) return null;
@@ -82,7 +84,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const explicitThinkingId =
agent.thinkingOptionId && agent.thinkingOptionId !== "default"
agent?.thinkingOptionId && agent.thinkingOptionId !== "default"
? agent.thinkingOptionId
: null;
const selectedThinkingId =
@@ -93,10 +95,14 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
const displayMode =
agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent.currentModeId ||
agent?.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
"default";
if (!agent) {
return null;
}
return (
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
@@ -118,7 +124,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
agent.currentModeId ||
"default"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
@@ -157,7 +163,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-model-selector"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
@@ -199,9 +205,13 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Select thinking option"
testID="agent-thinking-selector"
>
<Brain size={12} color={theme.colors.foregroundMuted} style={{ marginTop: 1 }} />
<Brain
size={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
style={{ marginTop: 1 }}
/>
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
@@ -249,7 +259,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={20} color={theme.colors.foreground} />
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
</Pressable>
<AdaptiveModalSheet
@@ -271,7 +281,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-mode"
>
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{agent.availableModes.map((mode) => {
@@ -303,7 +313,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{models?.map((model) => {
@@ -342,7 +352,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-thinking"
>
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => {

View File

@@ -50,6 +50,10 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -95,12 +99,18 @@ export function AgentStreamView({
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
const isNearBottomRef = useRef(true);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const streamItemCountRef = useRef(0);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -184,8 +194,12 @@ export function AgentStreamView({
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
}
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onScroll(event);
}
},
[insets.bottom]
[insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
@@ -202,6 +216,35 @@ export function AgentStreamView({
[]
);
const scheduleAutoScroll = useCallback(
({ animated }: { animated: boolean }) => {
pendingAutoScrollAnimatedRef.current =
pendingAutoScrollAnimatedRef.current || animated;
if (pendingAutoScrollFrameRef.current !== null) {
return;
}
pendingAutoScrollFrameRef.current = requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
const shouldAnimate = pendingAutoScrollAnimatedRef.current;
pendingAutoScrollAnimatedRef.current = false;
scrollToBottomInternal({ animated: shouldAnimate });
});
},
[scrollToBottomInternal]
);
useEffect(() => {
return () => {
if (pendingAutoScrollFrameRef.current !== null) {
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
pendingAutoScrollFrameRef.current = null;
}
pendingAutoScrollAnimatedRef.current = false;
};
}, []);
useEffect(() => {
if (streamItems.length === 0) {
return;
@@ -221,9 +264,9 @@ export function AgentStreamView({
}
const shouldAnimate = hasScrolledInitially.current;
scrollToBottomInternal({ animated: shouldAnimate });
scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true;
}, [streamItems, scrollToBottomInternal]);
}, [streamItems, scheduleAutoScroll]);
function scrollToBottom() {
scrollToBottomInternal({ animated: true });
@@ -314,6 +357,7 @@ export function AgentStreamView({
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -646,14 +690,25 @@ export function AgentStreamView({
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
ListHeaderComponentStyle={headerGapStyle}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onLayout={
showDesktopWebScrollbar
? streamScrollbarMetrics.onLayout
: undefined
}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={
showDesktopWebScrollbar
? streamScrollbarMetrics.onContentSizeChange
: undefined
}
ListEmptyComponent={listEmptyComponent}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
@@ -666,9 +721,21 @@ export function AgentStreamView({
initialNumToRender={12}
windowSize={10}
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted
/>
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted
onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{/* Scroll to bottom button */}
{!isNearBottom && (

View File

@@ -1,164 +0,0 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { Fonts } from "@/constants/theme";
import { Theme } from "@/styles/theme";
interface AgentSlashCommand {
name: string;
description: string;
argumentHint: string;
}
interface CommandAutocompleteProps {
serverId: string;
agentId: string;
filter: string;
selectedIndex: number;
onSelect: (command: AgentSlashCommand) => void;
}
export function CommandAutocomplete({
serverId,
agentId,
filter,
selectedIndex,
onSelect,
}: CommandAutocompleteProps) {
const { theme } = useUnistyles();
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId,
agentId,
enabled: true,
});
// Filter commands based on input after /
const filterLower = filter.toLowerCase();
const filteredCommands = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
if (isLoading) {
return (
<View style={styles.container}>
<View style={styles.loadingItem}>
<Text style={styles.loadingText}>Loading commands...</Text>
</View>
</View>
);
}
if (isError) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {error?.message ?? "Failed to load"}</Text>
</View>
</View>
);
}
if (filteredCommands.length === 0) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>No commands found</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always">
{filteredCommands.map((cmd, index) => {
const isSelected = index === selectedIndex;
return (
<Pressable
key={cmd.name}
onPress={() => onSelect(cmd)}
style={[
styles.commandItem,
isSelected && {
backgroundColor: theme.colors.accent,
},
]}
>
<View style={styles.commandHeader}>
<Text style={styles.commandName}>/{cmd.name}</Text>
{cmd.argumentHint && (
<Text style={styles.commandArgs}>{cmd.argumentHint}</Text>
)}
</View>
<Text style={styles.commandDescription} numberOfLines={1}>
{cmd.description}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) {
const filterLower = filter.toLowerCase();
return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
maxHeight: 200,
},
scrollView: {
flexGrow: 0,
flexShrink: 1,
},
commandItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
commandHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
commandName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontFamily: Fonts.mono,
},
commandArgs: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
},
commandDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
loadingItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
loadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;

View File

@@ -58,7 +58,7 @@ export function DictationControls({
accessibilityLabel="Start voice dictation"
style={[styles.micButton, disabled && styles.buttonDisabled]}
>
<Mic size={16} color={theme.colors.foreground} />
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable>
);
}
@@ -88,7 +88,7 @@ export function DictationControls({
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
]}
>
<X size={14} color={theme.colors.foreground} />
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
{actionsDisabled ? (
<View style={styles.loadingContainer}>
@@ -100,7 +100,7 @@ export function DictationControls({
accessibilityLabel="Retry dictation"
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<RefreshCcw size={14} color={theme.colors.surface0} />
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
) : (
<>
@@ -109,14 +109,14 @@ export function DictationControls({
accessibilityLabel="Insert transcription"
style={[styles.actionButton, styles.actionButtonSecondary]}
>
<Check size={14} color={theme.colors.foreground} />
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send"
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<ArrowUp size={14} color={theme.colors.surface0} />
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
</>
)}
@@ -167,7 +167,7 @@ export function DictationOverlay({
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
]}
>
<X size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
<X size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
</Pressable>
<View style={overlayStyles.centerContainer}>
@@ -219,7 +219,7 @@ export function DictationOverlay({
]}
>
<RefreshCcw
size={20}
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
@@ -234,7 +234,7 @@ export function DictationOverlay({
]}
>
<Pencil
size={20}
size={theme.iconSize.lg}
color={theme.colors.palette.white}
strokeWidth={2.5}
/>
@@ -247,7 +247,7 @@ export function DictationOverlay({
]}
>
<ArrowUp
size={20}
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>

View File

@@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
headerLine: {
backgroundColor: theme.colors.surface1,

View File

@@ -18,10 +18,12 @@ export function DraggableList<T>({
onDragEnd,
style,
contentContainerStyle,
testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
refreshing,
onRefresh,
simultaneousGestureRef,
@@ -69,6 +71,7 @@ export function DraggableList<T>({
return (
<DraggableFlatList
testID={testID}
data={data}
keyExtractor={keyExtractor}
renderItem={handleRenderItem}

View File

@@ -16,10 +16,12 @@ export interface DraggableListProps<T> {
onDragEnd: (data: T[]) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string;
ListFooterComponent?: ReactElement | null;
ListHeaderComponent?: ReactElement | null;
ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean;
enableDesktopWebScrollbar?: boolean;
refreshing?: boolean;
onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */

View File

@@ -1,5 +1,5 @@
import { useCallback, useState, useRef, type ReactElement } from "react";
import { View, ScrollView } from "react-native";
import { useCallback, useRef, useState, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
closestCenter,
@@ -23,6 +23,10 @@ import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -99,14 +103,18 @@ export function DraggableList<T>({
onDragEnd,
style,
contentContainerStyle,
testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
// simultaneousGestureRef is native-only, ignored on web
}: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data);
const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
// Sync items with data prop
if (data !== items && !activeId) {
@@ -151,39 +159,59 @@ export function DraggableList<T>({
);
const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar;
return (
<ScrollView
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
<ScrollView
ref={scrollViewRef}
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}

View File

@@ -24,6 +24,14 @@ import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerSidebar] ${event}`, details);
}
function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet";
@@ -94,9 +102,18 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
mobileView,
desktopFileExplorerOpen,
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -165,9 +182,15 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
runOnJS(handleClose)("swipe-close-gesture");
} else {
animateToOpen();
}
@@ -240,15 +263,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
width: resizeWidth.value,
}));
// Mobile: full-screen overlay with gesture
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
<Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
@@ -264,7 +291,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
onClose={() => handleClose("header-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}
@@ -297,7 +324,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}

View File

@@ -5,6 +5,9 @@ import {
FlatList,
Image as RNImage,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView as RNScrollView,
Text,
@@ -46,7 +49,7 @@ import type {
ExplorerEntry,
ExplorerFile,
} from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
@@ -63,6 +66,10 @@ import {
type SortOption,
} from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -86,9 +93,13 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const { connectionStates } = useDaemonConnections();
const daemonProfile = connectionStates.get(serverId)?.daemon;
const { daemons } = useDaemonRegistry();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const agentExists = useSessionStore((state) =>
agentId && state.sessions[serverId]
? state.sessions[serverId]?.agents.has(agentId)
@@ -136,6 +147,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const [containerWidth, setContainerWidth] = useState(0);
const wasInlinePreviewVisibleRef = useRef(false);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
// Bottom sheet for file preview (mobile)
const previewSheetRef = useRef<BottomSheetModal>(null);
@@ -584,6 +597,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
});
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
);
if (!agentExists) {
return (
<View style={styles.centerState}>
@@ -657,7 +688,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
</View>
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="inline" />
<FilePreviewBody
preview={preview}
isLoading={isPreviewLoading}
variant="inline"
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
</View>
) : null}
@@ -704,15 +740,40 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</Animated.View>
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
@@ -741,15 +802,40 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View>
)}
</View>
@@ -775,7 +861,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<X size={20} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="sheet" />
<FilePreviewBody
preview={preview}
isLoading={isPreviewLoading}
variant="sheet"
showDesktopWebScrollbar={false}
/>
</BottomSheetModal>
) : null}
</View>
@@ -786,11 +877,36 @@ function FilePreviewBody({
preview,
isLoading,
variant,
showDesktopWebScrollbar,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
variant: "inline" | "sheet";
showDesktopWebScrollbar: boolean;
}) {
const enablePreviewDesktopScrollbar =
variant === "inline" && showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
if (isLoading && !preview) {
return (
<View style={styles.sheetCenterState}>
@@ -824,16 +940,37 @@ function FilePreviewBody({
);
}
return (
<RNScrollView style={styles.previewContent}>
<View style={styles.previewScrollContainer}>
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
ref={previewScrollRef}
style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<Text style={styles.codeText}>{preview.content}</Text>
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</RNScrollView>
</RNScrollView>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
@@ -852,15 +989,37 @@ function FilePreviewBody({
);
}
return (
<RNScrollView contentContainerStyle={styles.previewImageScrollContent}>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
</View>
);
}
@@ -1299,6 +1458,11 @@ const styles = StyleSheet.create((theme) => ({
previewContent: {
flex: 1,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
previewCodeScrollContent: {
paddingTop: theme.spacing[3],
paddingHorizontal: theme.spacing[3],

View File

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

View File

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

View File

@@ -12,9 +12,8 @@ import {
type NativeScrollEvent,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import {
Archive,
ChevronDown,
@@ -51,7 +50,13 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
// =============================================================================
// Git Actions Data Structure
@@ -85,11 +90,7 @@ interface GitActions {
}
function openURLInNewTab(url: string): void {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener");
} else {
void Linking.openURL(url);
}
void openExternalUrl(url);
}
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
@@ -473,9 +474,13 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
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);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd });
@@ -521,6 +526,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
@@ -627,17 +633,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
bodyHeightByPathRef.current[path] = height;
}, []);
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onScroll(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
);
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
}, []);
const handleDiffListLayout = useCallback(
(event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onLayout(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
);
const computeHeaderOffset = useCallback(
(path: string): number => {
@@ -800,10 +818,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
@@ -825,15 +847,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildHostAgentDraftRoute(serverId) as any);
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
}, [runArchiveWorktree, router, serverId, cwd, status]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
@@ -878,6 +901,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
@@ -891,12 +915,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
? undefined
: `${branchLabel} -> ${baseRefLabel}`;
}, [baseRefLabel, branchLabel]);
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const isMergedPullRequest = Boolean(prStatus?.isMerged);
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive =
isPaseoOwnedWorktree &&
!hasUncommittedChanges &&
(postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled =
@@ -960,7 +999,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar
? diffScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
@@ -973,11 +1018,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
);
}
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
// ==========================================================================
// Git Actions (Data-Oriented)
@@ -1067,16 +1110,28 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
}
// Update from base - only when not on base branch
if (!isOnBaseBranch) {
// Update/sync from base
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${baseRefLabel}`,
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
});
@@ -1099,8 +1154,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
// Rule 0: Post-ship in worktree -> Archive
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
}
// Rule 1: Uncommitted changes → Commit
if (hasUncommittedChanges) {
else if (hasUncommittedChanges) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
@@ -1111,7 +1170,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
else if (hasPullRequest) {
primaryActionId = "view-pr";
}
// Rule 4: Ahead of base → Ship action based on preference
// Rule 4: On base branch -> surface sync explicitly
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
}
// Rule 5: Ahead of base → Ship action based on preference
else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
@@ -1131,20 +1194,25 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
// Secondary actions: ship-related + merge from base + push (excluding primary)
const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
// Menu actions: archive worktree only
const menu = allActions.has("archive-worktree")
? [allActions.get("archive-worktree")!]
: [];
// Menu actions: none for now (all actionable items are in primary/secondary)
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
@@ -1335,7 +1403,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null}
<View style={styles.diffContainer}>{bodyContent}</View>
<View style={styles.diffContainer}>
{bodyContent}
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View>
</View>
);
}
@@ -1535,6 +1615,7 @@ const styles = StyleSheet.create((theme) => ({
diffContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,

View File

@@ -26,7 +26,7 @@ export function BackHeader({
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<ArrowLeft size={20} color={theme.colors.foregroundMuted} />
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>

View File

@@ -75,7 +75,7 @@ export function SidebarMenuToggle({
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={16} color={menuIconColor} />
<PanelLeft size={theme.iconSize.md} color={menuIconColor} />
)}
</HeaderToggleButton>
);

View File

@@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { useTauriDragHandlers } from "@/utils/tauri-window";
interface ScreenHeaderProps {
@@ -25,7 +29,7 @@ export function ScreenHeader({
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? 8 : 0;
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
// On Tauri macOS, enable window dragging and double-click to maximize
// Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed

View File

@@ -1,4 +1,11 @@
import { useCallback, useMemo, useState, useEffect, useRef } from "react";
import {
useCallback,
useMemo,
useState,
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
@@ -14,13 +21,13 @@ import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
@@ -43,11 +50,11 @@ import {
const DESKTOP_SIDEBAR_WIDTH = 320;
interface SlidingSidebarProps {
interface LeftSidebarProps {
selectedAgentId?: string;
}
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile =
@@ -57,7 +64,28 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`
)
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`
)
.join("|")
);
const activeServerIdFromPath = useMemo(
() => parseServerIdFromPathname(pathname),
[pathname]
@@ -70,7 +98,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
}, [activeServerId, daemons]);
const activeHostStatus = activeServerId
? (connectionStates.get(activeServerId)?.status ?? "idle")
? runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting"
: "idle";
const activeHostStatusColor =
activeHostStatus === "online"
@@ -84,32 +112,33 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
connectionStates.get(daemon.serverId)?.status ?? "idle"
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
),
})),
[connectionStates, daemons]
[daemons, runtime, runtimeConnectionStatusSignature]
);
const hostTriggerRef = useRef<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
string[]
>([]);
const {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({
isOpen,
} = useSidebarAgentsList({
serverId: activeServerId,
selectedProjectKeys,
selectedProjectFilterKeys,
});
useEffect(() => {
setSelectedProjectKeys([]);
setSelectedProjectFilterKeys([]);
}, [activeServerId]);
const {
translateX,
@@ -121,6 +150,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
closeGestureRef,
} = useSidebarAnimation();
const dragHandlers = useTauriDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -324,8 +354,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Render mobile sidebar
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
// On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -354,7 +385,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
@@ -367,10 +401,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
isOpen={isOpen}
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -416,7 +451,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Users
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -433,7 +468,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Settings
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -465,7 +500,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -474,7 +512,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
@@ -487,10 +528,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
isOpen={isOpen}
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -534,7 +576,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Users
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -550,7 +592,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
onPress={handleSettingsDesktop}
>
{({ hovered }) => (
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import {
View,
Text,
Image,
Pressable,
ActivityIndicator,
type LayoutChangeEvent,
@@ -20,7 +21,6 @@ import {
} from "react";
import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import * as Linking from "expo-linking";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -62,7 +62,7 @@ import {
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry } from "@/types/stream";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
@@ -71,12 +71,15 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
@@ -117,6 +120,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
}
`;
let webToolCallShimmerRegistered = false;
const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") {
@@ -143,6 +148,113 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
}
if (target instanceof Node && target.parentElement) {
return target.parentElement;
}
return fallback;
}
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
const computedStyle = window.getComputedStyle(element);
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
const isScrollableOverflow =
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
if (!isScrollableOverflow) {
return false;
}
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
if (delta > 0) {
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
}
return scrollPosition > SCROLL_EDGE_EPSILON;
}
function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
let current: HTMLElement | null = startElement;
while (current) {
if (canElementScrollInDirection(current, axis, delta)) {
return true;
}
if (current === detailRoot) {
break;
}
current = current.parentElement;
}
return false;
}
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 hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
if (!hasVerticalIntent && !hasHorizontalIntent) {
return false;
}
const canScrollVertically = hasVerticalIntent
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
return isVerticalDominant
? canScrollVertically || canScrollHorizontally
: canScrollHorizontally || canScrollVertically;
}
if (hasVerticalIntent) {
return canScrollVertically;
}
return canScrollHorizontally;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
@@ -177,6 +289,29 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22,
overflowWrap: "anywhere",
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePreviewSpacing: {
marginBottom: theme.spacing[2],
},
imagePill: {
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface1,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
@@ -190,8 +325,21 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({
image,
}: {
image: UserMessageImageAttachment;
}) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={userMessageStylesheet.imageThumbnail} />;
}
export const UserMessage = memo(function UserMessage({
message,
images = [],
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
@@ -201,8 +349,10 @@ export const UserMessage = memo(function UserMessage({
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton =
Platform.OS !== "web" || messageHovered || copyButtonHovered;
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return (
<View
@@ -227,21 +377,42 @@ export const UserMessage = memo(function UserMessage({
}
>
<View style={userMessageStylesheet.bubble}>
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
{hasImages ? (
<View
style={[
userMessageStylesheet.imagePreviewContainer,
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
]}
>
{images.map((image, index) => (
<View
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
) : null}
</View>
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
{hasText ? (
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
) : null}
</Pressable>
</View>
);
@@ -540,12 +711,10 @@ export const AssistantMessage = memo(function AssistantMessage({
);
const handleLinkPress = useCallback((url: string) => {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
} else {
void Linking.openURL(url);
}
return true;
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;
}, []);
const markdownRules = useMemo(() => {
@@ -1123,6 +1292,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
@@ -1242,6 +1412,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
shimmerTranslateX,
]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
return;
}
const node = detailWrapperRef.current as unknown as HTMLElement | null;
if (!node || typeof node.addEventListener !== "function") {
return;
}
const stopWheelPropagation = (event: WheelEvent) => {
if (shouldStopDetailWheelPropagation(node, event)) {
event.stopPropagation();
}
};
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [isExpanded, hasDetailContent]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
@@ -1478,6 +1670,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Pressable>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
onHoverIn={() => onDetailHoverChange?.(true)}
onHoverOut={() => onDetailHoverChange?.(false)}

View File

@@ -1,155 +1,23 @@
import { useEffect } from "react";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import {
useDaemonRegistry,
type HostConnection,
type HostProfile,
} from "@/contexts/daemon-registry-context";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import {
selectBestConnection,
type ConnectionCandidate,
type ConnectionProbeState,
} from "@/utils/connection-selection";
import { useQueries } from "@tanstack/react-query";
import { useMemo } from "react";
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
type Candidate = {
connection: HostConnection;
connectionId: string;
url: string;
activeConnection: ActiveConnection;
daemonPublicKeyB64?: string;
};
function sortConnectionsByPreference<T extends { id: string }>(
connections: T[],
preferredId: string | null
): T[] {
if (!preferredId) return connections;
const idx = connections.findIndex((c) => c.id === preferredId);
if (idx === -1) return connections;
return [connections[idx]!, ...connections.slice(0, idx), ...connections.slice(idx + 1)];
}
function buildCandidates(host: HostProfile): Candidate[] {
const preferred = host.preferredConnectionId ?? null;
const connections = sortConnectionsByPreference(host.connections, preferred);
const out: Candidate[] = [];
for (const conn of connections) {
if (conn.type === "direct") {
out.push({
connection: conn,
connectionId: conn.id,
url: buildDaemonWebSocketUrl(conn.endpoint),
activeConnection: {
type: "direct",
endpoint: conn.endpoint,
display: conn.endpoint,
},
});
continue;
}
out.push({
connection: conn,
connectionId: conn.id,
url: buildRelayWebSocketUrl({
endpoint: conn.relayEndpoint,
serverId: host.serverId,
}),
activeConnection: { type: "relay", endpoint: conn.relayEndpoint, display: "relay" },
daemonPublicKeyB64: conn.daemonPublicKeyB64,
});
}
return out;
}
getHostRuntimeStore,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
const latencyQueries = useQueries({
queries: candidates.map((candidate) => ({
queryKey: ["connection-selection-latency", daemon.serverId, candidate.connectionId],
queryFn: () =>
measureConnectionLatency(candidate.connection, {
serverId: daemon.serverId,
}),
refetchInterval: 10_000,
staleTime: 9_000,
gcTime: 60_000,
retry: 1,
})),
});
const { client } = useHostRuntimeSession(daemon.serverId);
const probeByConnectionId = useMemo(() => {
const next = new Map<string, ConnectionProbeState>();
candidates.forEach((candidate, index) => {
const query = latencyQueries[index];
if (!query) {
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
return;
}
if (query.isSuccess && typeof query.data === "number") {
next.set(candidate.connectionId, {
status: "available",
latencyMs: query.data,
});
return;
}
if (query.isError) {
next.set(candidate.connectionId, { status: "unavailable", latencyMs: null });
return;
}
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
});
return next;
}, [candidates, latencyQueries]);
const candidateInputs = useMemo<ConnectionCandidate[]>(
() =>
candidates.map((candidate) => ({
connectionId: candidate.connectionId,
connection: candidate.connection,
})),
[candidates]
);
const activeConnectionId = useMemo(
() =>
selectBestConnection({
candidates: candidateInputs,
preferredConnectionId: daemon.preferredConnectionId,
probeByConnectionId,
}),
[candidateInputs, daemon.preferredConnectionId, probeByConnectionId]
);
const active =
candidates.find((candidate) => candidate.connectionId === activeConnectionId) ?? null;
const activeUrl = active?.url ?? null;
if (!activeUrl) {
if (!client) {
return null;
}
return (
<SessionProvider
key={daemon.serverId}
serverUrl={activeUrl}
serverId={daemon.serverId}
activeConnection={active?.activeConnection ?? null}
daemonPublicKeyB64={active?.daemonPublicKeyB64}
client={client}
>
{null}
</SessionProvider>
@@ -158,6 +26,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
useEffect(() => {
const runtime = getHostRuntimeStore();
runtime.syncHosts(daemons);
}, [daemons]);
if (daemons.length === 0) {
return null;
}

View File

@@ -54,9 +54,9 @@ export function RealtimeVoiceOverlay({
]}
>
{isMuted ? (
<MicOff size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
) : (
<Mic size={20} color={theme.colors.foreground} strokeWidth={2.5} />
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} strokeWidth={2.5} />
)}
</Pressable>
@@ -75,7 +75,7 @@ export function RealtimeVoiceOverlay({
<ActivityIndicator size="small" color={theme.colors.palette.white} />
) : (
<Square
size={20}
size={theme.iconSize.lg}
color={theme.colors.palette.white}
fill={theme.colors.palette.white}
strokeWidth={2.5}

View File

@@ -4,6 +4,7 @@ import {
Pressable,
Image,
Platform,
ActivityIndicator,
} from "react-native";
import { useQueries } from "@tanstack/react-query";
import {
@@ -16,14 +17,21 @@ import {
type MutableRefObject,
} from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
StyleSheet,
UnistylesRuntime,
useUnistyles,
} from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { Archive, Check, ChevronDown } from "lucide-react-native";
import {
DraggableList,
type DraggableRenderItemInfo,
} from "./draggable-list";
import { useSessionStore } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
} from "@/runtime/host-runtime";
import {
buildAgentNavigationKey,
startNavigationTiming,
@@ -35,8 +43,9 @@ import {
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
type SidebarAgentListEntry,
type SidebarProjectOption,
} from "@/hooks/use-sidebar-agents-grouped";
type SidebarProjectFilterOption,
} from "@/hooks/use-sidebar-agents-list";
import type { ProjectIcon } from "@server/shared/messages";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -45,14 +54,23 @@ import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping";
import { formatTimeAgo } from "@/utils/time";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
type EntryData = SidebarAgentListEntry;
function toProjectIconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}
interface SidebarAgentListProps {
isOpen?: boolean;
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
selectedProjectKeys: string[];
onSelectedProjectKeysChange: (keys: string[]) => void;
projectFilterOptions: SidebarProjectFilterOption[];
selectedProjectFilterKeys: string[];
onSelectedProjectFilterKeysChange: (keys: string[]) => void;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
@@ -63,7 +81,7 @@ interface SidebarAgentListProps {
}
interface ProjectFilterOptionRowProps {
option: SidebarProjectOption;
option: SidebarProjectFilterOption;
selected: boolean;
iconDataUri: string | null;
displayName: string;
@@ -110,7 +128,7 @@ function ProjectFilterOptionRow({
{option.activeCount > 0 ? (
<Text style={styles.filterOptionCount}>{option.activeCount}</Text>
) : null}
{selected ? <Check size={14} color={theme.colors.foregroundMuted} /> : null}
{selected ? <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> : null}
</View>
</Pressable>
);
@@ -123,10 +141,11 @@ interface SidebarAgentRowProps {
isSelected: boolean;
isInSelectionMode: boolean;
isBatchSelected: boolean;
isArchiving: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: () => void;
onArchive: () => Promise<void>;
onToggleBatch: () => void;
}
@@ -149,6 +168,7 @@ function SidebarAgentRow({
isSelected,
isInSelectionMode,
isBatchSelected,
isArchiving,
shortcutNumber,
onPress,
onLongPress,
@@ -172,7 +192,7 @@ function SidebarAgentRow({
const showArchive =
!isInSelectionMode &&
shortcutNumber === null &&
(isHovered || isArchiveHovered || isArchiveConfirmVisible);
(isHovered || isArchiveHovered || isArchiveConfirmVisible || isArchiving);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
@@ -186,6 +206,12 @@ function SidebarAgentRow({
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
useEffect(() => {
if (!isArchiving) {
setIsArchiveConfirmVisible(false);
}
}, [isArchiving]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
@@ -233,7 +259,7 @@ function SidebarAgentRow({
]}
>
{isBatchSelected ? (
<Check size={12} color={theme.colors.primaryForeground} />
<Check size={theme.iconSize.xs} color={theme.colors.primaryForeground} />
) : null}
</Pressable>
) : (
@@ -266,26 +292,31 @@ function SidebarAgentRow({
}}
onPress={(event) => {
event.stopPropagation();
if (isArchiving) {
return;
}
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive();
setIsArchiveConfirmVisible(false);
void onArchive();
}}
style={styles.archiveButton}
disabled={isArchiving}
testID={
isArchiveConfirmVisible
isArchiveConfirmVisible || isArchiving
? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}`
: `agent-archive-${entry.agent.serverId}-${entry.agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
<Check size={12} color={theme.colors.foreground} />
isArchiving ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : isArchiveConfirmVisible ? (
<Check size={theme.iconSize.xs} color={theme.colors.foreground} />
) : (
<Archive
size={12}
size={theme.iconSize.xs}
color={
archiveHovered
? theme.colors.foreground
@@ -359,15 +390,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
}
function resolveSelectedProjectLabel(input: {
selectedProjectKeys: string[];
projectOptions: SidebarProjectOption[];
selectedProjectFilterKeys: string[];
projectFilterOptions: SidebarProjectFilterOption[];
}): string {
if (input.selectedProjectKeys.length === 0) {
if (input.selectedProjectFilterKeys.length === 0) {
return "Project";
}
if (input.selectedProjectKeys.length === 1) {
const selected = input.projectOptions.find(
(option) => option.projectKey === input.selectedProjectKeys[0]
if (input.selectedProjectFilterKeys.length === 1) {
const selected = input.projectFilterOptions.find(
(option) => option.projectKey === input.selectedProjectFilterKeys[0]
);
if (selected) {
return deriveProjectDisplayName({
@@ -377,7 +408,7 @@ function resolveSelectedProjectLabel(input: {
});
}
return deriveProjectDisplayName({
projectKey: input.selectedProjectKeys[0] ?? "",
projectKey: input.selectedProjectFilterKeys[0] ?? "",
projectName: "",
remoteUrl: null,
});
@@ -421,10 +452,11 @@ function deriveProjectDisplayName(input: {
}
export function SidebarAgentList({
isOpen = true,
entries,
projectOptions,
selectedProjectKeys,
onSelectedProjectKeysChange,
projectFilterOptions,
selectedProjectFilterKeys,
onSelectedProjectFilterKeysChange,
isRefreshing = false,
onRefresh,
selectedAgentId,
@@ -434,11 +466,15 @@ export function SidebarAgentList({
}: SidebarAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set());
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
@@ -453,23 +489,26 @@ export function SidebarAgentList({
);
const selectedProjectLabel = useMemo(
() => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }),
[projectOptions, selectedProjectKeys]
() => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }),
[projectFilterOptions, selectedProjectFilterKeys]
);
const selectedProjectOption = useMemo(() => {
if (selectedProjectKeys.length !== 1) {
if (selectedProjectFilterKeys.length !== 1) {
return null;
}
return (
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null
projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null
);
}, [projectOptions, selectedProjectKeys]);
}, [projectFilterOptions, selectedProjectFilterKeys]);
const showProjectFilters = projectFilterOptions.length > 0;
const sessions = useSessionStore((state) => state.sessions);
const projectIconRequests = useMemo(() => {
if (!isOpen) {
return [];
}
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
if (!option.serverId || !option.workingDir) {
continue;
}
@@ -487,23 +526,26 @@ export function SidebarAgentList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [entries, projectOptions]);
}, [entries, isOpen, projectFilterOptions]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
queryKey: projectIconQueryKey(request.serverId, request.cwd),
queryFn: async () => {
const client =
useSessionStore.getState().sessions[request.serverId]?.client ?? null;
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) {
return null;
}
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: toProjectIconDataUri,
enabled: Boolean(
sessions[request.serverId]?.client &&
sessions[request.serverId]?.connection.isConnected &&
isOpen &&
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(
getHostRuntimeStore().getSnapshot(request.serverId)
) &&
request.cwd
),
staleTime: Infinity,
@@ -521,10 +563,7 @@ export function SidebarAgentList({
if (!request) {
continue;
}
const icon = projectIconQueries[i]?.data ?? null;
const dataUri = icon
? `data:${icon.mimeType};base64,${icon.data}`
: null;
const dataUri = projectIconQueries[i]?.data ?? null;
map.set(`${request.serverId}:${request.cwd}`, dataUri);
}
return map;
@@ -532,7 +571,7 @@ export function SidebarAgentList({
const projectIconByProjectKey = useMemo(() => {
const map = new Map<string, string | null>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
map.set(
option.projectKey,
projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null
@@ -550,7 +589,7 @@ export function SidebarAgentList({
);
}
return map;
}, [entries, projectIconByQueryKey, projectOptions]);
}, [entries, projectIconByQueryKey, projectFilterOptions]);
const selectedProjectIconUri = useMemo(() => {
if (!selectedProjectOption) {
@@ -561,20 +600,20 @@ export function SidebarAgentList({
const handleToggleProject = useCallback(
(projectKey: string) => {
const next = new Set(selectedProjectKeys);
const next = new Set(selectedProjectFilterKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
onSelectedProjectKeysChange(Array.from(next));
onSelectedProjectFilterKeysChange(Array.from(next));
},
[onSelectedProjectKeysChange, selectedProjectKeys]
[onSelectedProjectFilterKeysChange, selectedProjectFilterKeys]
);
const handleClearProjectFilter = useCallback(() => {
onSelectedProjectKeysChange([]);
}, [onSelectedProjectKeysChange]);
onSelectedProjectFilterKeysChange([]);
}, [onSelectedProjectFilterKeysChange]);
const handleAgentPress = useCallback(
(entry: SidebarAgentListEntry) => {
@@ -592,11 +631,6 @@ export function SidebarAgentList({
return;
}
const session = useSessionStore.getState().sessions[entry.agent.serverId];
if (session?.client) {
session.client.clearAgentAttention(entry.agent.id);
}
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
startNavigationTiming(navigationKey, {
from: "home",
@@ -619,15 +653,17 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set([key]));
}, []);
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => {
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null;
if (!client) {
return;
}
void client.archiveAgent(entry.agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
}, []);
const handleArchiveSingle = useCallback(
async (entry: SidebarAgentListEntry): Promise<void> => {
await archiveAgent({
serverId: entry.agent.serverId,
agentId: entry.agent.id,
}).catch((error) => {
console.warn("[archive_agent] failed", error);
});
},
[archiveAgent]
);
const handleArchiveBatch = useCallback(() => {
if (selectedBatchKeys.size === 0) {
@@ -636,23 +672,18 @@ export function SidebarAgentList({
}
const requests: Promise<void>[] = [];
const store = useSessionStore.getState();
for (const key of selectedBatchKeys) {
const parsed = parseSidebarAgentKey(key);
if (!parsed) {
continue;
}
const client = store.sessions[parsed.serverId]?.client ?? null;
if (!client) {
continue;
}
requests.push(
client
.archiveAgent(parsed.agentId)
.then(() => undefined)
.catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
archiveAgent({
serverId: parsed.serverId,
agentId: parsed.agentId,
}).catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
);
}
@@ -660,7 +691,7 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set());
setIsSelectionMode(false);
});
}, [selectedBatchKeys]);
}, [archiveAgent, selectedBatchKeys]);
const handleSelectionBack = useCallback(() => {
setSelectedBatchKeys(new Set());
@@ -685,6 +716,10 @@ export function SidebarAgentList({
isSelected={selectedAgentId === key}
isInSelectionMode={isSelectionMode}
isBatchSelected={selectedBatchKeys.has(key)}
isArchiving={isArchivingAgent({
serverId: item.agent.serverId,
agentId: item.agent.id,
})}
shortcutNumber={
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
}
@@ -709,6 +744,7 @@ export function SidebarAgentList({
handleAgentLongPress,
handleAgentPress,
handleArchiveSingle,
isArchivingAgent,
isSelectionMode,
selectedAgentId,
selectedBatchKeys,
@@ -725,98 +761,100 @@ export function SidebarAgentList({
return (
<View style={styles.container}>
<View style={styles.filtersRow}>
<Pressable
ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [
styles.filterTrigger,
(selectedProjectKeys.length > 0 || hovered || pressed) &&
styles.filterTriggerActive,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered = false, pressed }) => {
const isInteracting = hovered || pressed;
const showActiveForeground =
selectedProjectKeys.length > 0 || isInteracting;
return (
<>
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
<Image
source={{
uri: selectedProjectIconUri,
}}
style={styles.selectedProjectIcon}
{showProjectFilters ? (
<View style={styles.filtersRow}>
<Pressable
ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [
styles.filterTrigger,
(selectedProjectFilterKeys.length > 0 || hovered || pressed) &&
styles.filterTriggerActive,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered = false, pressed }) => {
const isInteracting = hovered || pressed;
const showActiveForeground =
selectedProjectFilterKeys.length > 0 || isInteracting;
return (
<>
{selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? (
<Image
source={{
uri: selectedProjectIconUri,
}}
style={styles.selectedProjectIcon}
/>
) : selectedProjectFilterKeys.length > 1 ? (
<View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}>
{selectedProjectFilterKeys.length}
</Text>
</View>
) : null}
<Text
style={[
styles.filterTriggerText,
!showActiveForeground && styles.filterTriggerTextMuted,
]}
numberOfLines={1}
>
{selectedProjectLabel}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={
showActiveForeground
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : selectedProjectKeys.length > 1 ? (
<View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}>
{selectedProjectKeys.length}
</Text>
</View>
) : null}
<Text
style={[
styles.filterTriggerText,
!showActiveForeground && styles.filterTriggerTextMuted,
]}
numberOfLines={1}
>
{selectedProjectLabel}
</Text>
<ChevronDown
size={14}
color={
showActiveForeground
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</>
);
}}
</Pressable>
{selectedProjectKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text>
</>
);
}}
</Pressable>
) : null}
<Combobox
options={[]}
value=""
onSelect={() => {}}
title="Filter by project"
placeholder="Search projects"
searchPlaceholder="Search projects"
desktopPlacement="bottom-start"
open={isProjectFilterOpen}
onOpenChange={setIsProjectFilterOpen}
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
{projectOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
projectName: option.projectName,
remoteUrl: null,
})}
onToggle={handleToggleProject}
/>
))
)}
</View>
</Combobox>
</View>
{selectedProjectFilterKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text>
</Pressable>
) : null}
<Combobox
options={[]}
value=""
onSelect={() => {}}
title="Filter by project"
placeholder="Search projects"
searchPlaceholder="Search projects"
desktopPlacement="bottom-start"
open={isProjectFilterOpen}
onOpenChange={setIsProjectFilterOpen}
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
{projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectFilterOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectFilterKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
projectName: option.projectName,
remoteUrl: null,
})}
onToggle={handleToggleProject}
/>
))
)}
</View>
</Combobox>
</View>
) : null}
<DraggableList
data={entries}
@@ -825,10 +863,12 @@ export function SidebarAgentList({
styles.listContent,
isSelectionMode ? styles.listContentSelectionMode : null,
]}
testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={() => {}}
showsVerticalScrollIndicator={false}
enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent}
refreshing={isRefreshing}
onRefresh={onRefresh}
@@ -894,13 +934,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
selectedProjectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
projectCountBadge: {
minWidth: 16,
height: 16,
minWidth: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -965,13 +1005,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
projectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -1017,8 +1057,8 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 20,
},
checkbox: {
width: 16,
height: 16,
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -1078,8 +1118,8 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
agentMetaProjectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
agentMetaProjectIconInactive: {

View File

@@ -29,6 +29,7 @@ interface TerminalEmulatorProps {
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
declare global {
@@ -51,6 +52,7 @@ export default function TerminalEmulator({
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
@@ -142,6 +144,13 @@ export default function TerminalEmulator({
runtimeRef.current?.focus();
}, [focusRequestToken]);
useEffect(() => {
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
return (
<div
ref={rootRef}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect } from "@react-navigation/native";
import {
ActivityIndicator,
Pressable,
@@ -15,12 +16,15 @@ import Svg, {
Stop,
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
TerminalOutputPump,
type TerminalOutputChunk,
@@ -43,6 +47,7 @@ interface TerminalPaneProps {
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -89,6 +94,8 @@ type PendingTerminalInput =
};
};
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -128,12 +135,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const queryClient = useQueryClient();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const { client, isConnected } = useHostRuntimeSession(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
@@ -154,6 +159,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
null
@@ -245,8 +251,36 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1);
}, []);
useFocusEffect(
useCallback(() => {
if (!selectedTerminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to an agent.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [requestTerminalReflow, selectedTerminalId])
);
const terminalsQuery = useQuery({
queryKey: ["terminals", serverId, cwd] as const,
queryKey: terminalsQueryKey,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
@@ -281,14 +315,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
});
}, [client, cwd, isConnected, queryClient, serverId]);
}, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) {
@@ -303,10 +337,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return;
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
});
@@ -317,7 +351,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [client, cwd, isConnected, queryClient, serverId]);
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
@@ -327,12 +361,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
if (payload.terminal) {
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
setSelectedTerminalId(payload.terminal.id);
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
return {
cwd: current?.cwd ?? cwd,
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
setSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
},
});
@@ -358,10 +406,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
},
@@ -538,22 +586,31 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [activeStreamId, flushPendingTerminalInput]);
const handleCloseTerminal = useCallback(
(terminalId: string) => {
async (terminalId: string) => {
if (
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
) {
return;
}
const confirmed = await confirmDialog({
title: "Close terminal?",
message: "Any running process in this terminal will be stopped immediately.",
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
killTerminalMutation.mutate(terminalId);
},
[killTerminalMutation]
);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
}, []);
@@ -836,7 +893,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
onPress={(event) => {
event.stopPropagation();
handleCloseTerminal(terminal.id);
void handleCloseTerminal(terminal.id);
}}
style={({ hovered, pressed }) => [
styles.terminalTabCloseButton,
@@ -919,6 +976,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
resizeRequestToken={resizeRequestToken}
/>
</View>
) : (

View File

@@ -48,7 +48,8 @@ export function ToolCallDetailsContent({
(detail?.type === "shell" ||
detail?.type === "edit" ||
detail?.type === "write" ||
detail?.type === "read");
detail?.type === "read" ||
detail?.type === "sub_agent");
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
if (detail?.type === "shell") {
@@ -126,6 +127,45 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "sub_agent") {
const activityLog = detail.log.replace(/^\n+/, "");
const hasLog = activityLog.length > 0;
const fallbackHeader =
detail.subAgentType && detail.description
? `${detail.subAgentType}: ${detail.description}`
: detail.subAgentType ?? detail.description ?? "Sub-agent activity";
sections.push(
<View
key="sub-agent"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog ? activityLog : fallbackHeader}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View
@@ -393,6 +433,12 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
shellPrompt: {
color: theme.colors.foregroundMuted,

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
getAutocompleteFallbackIndex,
getAutocompleteScrollOffset,
orderAutocompleteOptions,
} from "./autocomplete-utils";
const OPTIONS = ["alpha", "beta", "gamma"];
describe("orderAutocompleteOptions", () => {
it("keeps first logical option closest to the input by default", () => {
expect(orderAutocompleteOptions(OPTIONS)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
it("keeps normal top-down order when below-input is selected", () => {
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([
"alpha",
"beta",
"gamma",
]);
});
});
describe("getAutocompleteFallbackIndex", () => {
it("picks the option nearest the input by default", () => {
expect(getAutocompleteFallbackIndex(3)).toBe(2);
expect(getAutocompleteFallbackIndex(0)).toBe(-1);
});
it("picks top item when below-input ordering is used", () => {
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
});
});
describe("getAutocompleteScrollOffset", () => {
it("scrolls up when the active item is above the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 120,
viewportHeight: 80,
itemTop: 90,
itemHeight: 20,
})
).toBe(90);
});
it("scrolls down when the active item is below the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 0,
viewportHeight: 100,
itemTop: 150,
itemHeight: 24,
})
).toBe(74);
});
});

View File

@@ -0,0 +1,56 @@
import { getNextActiveIndex } from "./combobox-keyboard";
export type AutocompleteOptionsPosition = "above-input" | "below-input";
export function orderAutocompleteOptions<T>(
options: readonly T[],
position: AutocompleteOptionsPosition = "above-input"
): T[] {
if (position === "below-input") {
return [...options];
}
return [...options].reverse();
}
export function getAutocompleteFallbackIndex(
itemCount: number,
position: AutocompleteOptionsPosition = "above-input"
): number {
if (itemCount <= 0) {
return -1;
}
return position === "above-input" ? itemCount - 1 : 0;
}
export function getAutocompleteNextIndex(args: {
currentIndex: number;
itemCount: number;
key: "ArrowDown" | "ArrowUp";
}): number {
return getNextActiveIndex(args);
}
export function getAutocompleteScrollOffset(args: {
currentOffset: number;
viewportHeight: number;
itemTop: number;
itemHeight: number;
}): number {
if (args.viewportHeight <= 0) {
return args.currentOffset;
}
const itemBottom = args.itemTop + args.itemHeight;
const viewportTop = args.currentOffset;
const viewportBottom = args.currentOffset + args.viewportHeight;
if (args.itemTop < viewportTop) {
return Math.max(0, args.itemTop);
}
if (itemBottom > viewportBottom) {
return Math.max(0, itemBottom - args.viewportHeight);
}
return args.currentOffset;
}

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { File, Folder } from 'lucide-react-native'
import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
export interface AutocompleteOption {
id: string
label: string
detail?: string
description?: string
kind?: 'command' | 'file' | 'directory'
}
interface AutocompleteProps {
options: readonly AutocompleteOption[]
selectedIndex: number
onSelect: (option: AutocompleteOption) => void
isLoading?: boolean
errorMessage?: string
loadingText?: string
emptyText?: string
maxHeight?: number
}
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g
function removeBoltGlyphs(value?: string): string | undefined {
if (!value) {
return value
}
const cleaned = value.replace(BOLT_GLYPH_PATTERN, '').trim()
return cleaned.length > 0 ? cleaned : undefined
}
export function Autocomplete({
options,
selectedIndex,
onSelect,
isLoading = false,
errorMessage,
loadingText = 'Loading...',
emptyText = 'No results found',
maxHeight = 220,
}: AutocompleteProps) {
const { theme } = useUnistyles()
const scrollRef = useRef<ScrollView>(null)
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
const viewportHeightRef = useRef(0)
const scrollOffsetRef = useRef(0)
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return
}
const layout = rowLayoutsRef.current.get(selectedIndex)
if (!layout) {
return
}
const nextOffset = getAutocompleteScrollOffset({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
})
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return
}
scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex])
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false })
})
}, [])
useEffect(() => {
rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0
}, [options])
useEffect(() => {
if (options.length === 0) {
return
}
pinToBottom()
}, [options, pinToBottom])
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible)
return () => {
cancelAnimationFrame(raf)
}
}, [ensureActiveItemVisible, options.length])
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height
ensureActiveItemVisible()
},
[ensureActiveItemVisible]
)
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
})
ensureActiveItemVisible()
},
[ensureActiveItemVisible]
)
if (isLoading) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
</View>
)
}
if (errorMessage) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
)
}
if (options.length === 0) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
</View>
)
}
return (
<View style={[styles.container, { maxHeight }]}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
}}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex
const optionLabel = removeBoltGlyphs(option.label) ?? option.label
const optionDetail = removeBoltGlyphs(option.detail)
const optionDescription = removeBoltGlyphs(option.description)
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{option.kind === 'directory' || option.kind === 'file' ? (
<View style={styles.itemLeading}>
{option.kind === 'directory' ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</Pressable>
)
})}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
},
scrollView: {
flexGrow: 0,
flexShrink: 1,
},
scrollContent: {
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: theme.spacing[1],
},
itemActive: {
backgroundColor: theme.colors.surface1,
},
itemMain: {
flex: 1,
minWidth: 0,
},
itemHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
itemLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
itemDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
itemDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
} from "./combobox-options";
describe("buildVisibleComboboxOptions", () => {
const options = [
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("keeps a custom row visible while searching with no matches", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/tmp/new-project",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toHaveLength(1);
expect(visible[0]).toEqual({
id: "/tmp/new-project",
label: "/tmp/new-project",
description: undefined,
kind: "directory",
});
});
it("does not duplicate a row when search exactly matches an existing option", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/Users/me/project-a",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toEqual([
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
]);
});
});
describe("combobox above-search ordering", () => {
const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("renders first logical option closest to the search box in above-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "above-search");
expect(ordered.map((option) => option.id)).toEqual([
"/Users/me/project-b",
"/Users/me/project-a",
"/tmp/new-project",
]);
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
});
it("keeps normal top-down order in below-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "below-search");
expect(ordered.map((option) => option.id)).toEqual([
"/tmp/new-project",
"/Users/me/project-a",
"/Users/me/project-b",
]);
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});

View File

@@ -0,0 +1,95 @@
export type ComboboxOptionKind = 'directory' | 'file'
export interface ComboboxOptionModel {
id: string
label: string
description?: string
kind?: ComboboxOptionKind
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
customValuePrefix: string
customValueDescription?: string
customValueKind?: ComboboxOptionKind
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim()
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
)
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
)
: input.options
const sanitizedSearchValue = input.searchQuery.trim()
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
})
const visibleOptions: ComboboxOptionModel[] = []
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim()
const customLabel =
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
})
}
visibleOptions.push(...filteredOptions)
return visibleOptions
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: 'below-search' | 'above-search'
): ComboboxOptionModel[] {
if (optionsPosition !== 'above-search') {
return visibleOptions
}
return [...visibleOptions].reverse()
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: 'below-search' | 'above-search'
): number {
if (itemCount <= 0) {
return -1
}
return optionsPosition === 'above-search' ? itemCount - 1 : 0
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from 'react'
import {
View,
Text,
@@ -9,63 +9,89 @@ import {
ScrollView,
Platform,
StatusBar,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
useWindowDimensions,
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Search } from "lucide-react-native";
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
} from '@gorhom/bottom-sheet'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Check, File, Folder, Search } from 'lucide-react-native'
import {
flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from '@floating-ui/react-native'
import { getNextActiveIndex } from './combobox-keyboard'
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from './combobox-options'
import type { ComboboxOptionModel } from './combobox-options'
const IS_WEB = Platform.OS === "web";
const IS_WEB = Platform.OS === 'web'
export interface ComboboxOption {
id: string;
label: string;
description?: string;
}
export type ComboboxOption = ComboboxOptionModel
export interface ComboboxProps {
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
options: ComboboxOption[]
value: string
onSelect: (id: string) => void
onSearchQueryChange?: (query: string) => void
searchable?: boolean
placeholder?: string
searchPlaceholder?: string
emptyText?: string
allowCustomValue?: boolean
customValuePrefix?: string
customValueDescription?: string
customValueKind?: 'directory' | 'file'
optionsPosition?: 'below-search' | 'above-search'
title?: string
open?: boolean
onOpenChange?: (open: boolean) => void
desktopPlacement?: 'top-start' | 'bottom-start'
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean
anchorRef: React.RefObject<View | null>
children?: ReactNode
}
function toNumericStyleValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return null
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
}
interface SearchInputProps {
placeholder: string;
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
placeholder: string
value: string
onChangeText: (text: string) => void
onSubmitEditing?: () => void
autoFocus?: boolean
}
function SearchInput({
@@ -75,18 +101,18 @@ function SearchInput({
onSubmitEditing,
autoFocus = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
inputRef.current?.focus()
}, 50)
return () => clearTimeout(timer)
}
}, [autoFocus]);
}, [autoFocus])
return (
<View style={styles.searchInputContainer}>
@@ -94,7 +120,7 @@ function SearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -104,27 +130,29 @@ function SearchInput({
onSubmitEditing={onSubmitEditing}
/>
</View>
);
)
}
export interface ComboboxItemProps {
label: string;
description?: string;
selected?: boolean;
active?: boolean;
onPress: () => void;
testID?: string;
label: string
description?: string
kind?: 'directory' | 'file'
selected?: boolean
active?: boolean
onPress: () => void
testID?: string
}
export function ComboboxItem({
label,
description,
kind,
selected,
active,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
const { theme } = useUnistyles()
return (
<Pressable
testID={testID}
@@ -136,10 +164,23 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null}
</View>
{selected ? (
@@ -148,11 +189,15 @@ export function ComboboxItem({
</View>
) : null}
</Pressable>
);
)
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text style={styles.emptyText}>{children}</Text>;
return (
<Text testID="combobox-empty-text" style={styles.emptyText}>
{children}
</Text>
)
}
export function Combobox({
@@ -161,281 +206,353 @@ export function Combobox({
onSelect,
onSearchQueryChange,
searchable = true,
placeholder = "Search...",
placeholder = 'Search...',
searchPlaceholder,
emptyText = "No options match your search.",
emptyText = 'No options match your search.',
allowCustomValue = false,
customValuePrefix = "Use",
customValuePrefix = 'Use',
customValueDescription,
title = "Select",
customValueKind,
optionsPosition = 'below-search',
title = 'Select',
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPlacement = 'top-start',
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const isDesktopAboveSearch =
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
)
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = isControlled ? open : internalOpen;
const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
setInternalOpen(nextOpen)
}
onOpenChange?.(nextOpen);
onOpenChange?.(nextOpen)
},
[isControlled, onOpenChange]
);
)
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery)
},
[onSearchQueryChange]
);
)
const handleClose = useCallback(() => {
setOpen(false);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
setOpen(false)
setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback])
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback("");
setSearchQueryWithCallback('')
}
}, [isOpen, setSearchQueryWithCallback]);
}, [isOpen, setSearchQueryWithCallback])
const collisionPadding = useMemo(() => {
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const basePadding = 16
if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding)
}, [])
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
shift({ padding: collisionPadding }),
floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight };
if (!prev) return next;
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
const next = { width: availableWidth, height: availableHeight }
if (!prev) return next
if (prev.width === next.width && prev.height === next.height) return prev
return next
})
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
const next = rects.reference.width
if (prev === next) return prev
return next
})
},
}),
],
[collisionPadding]
);
[collisionPadding, isDesktopAboveSearch]
)
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
});
})
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
setReferenceWidth(null);
return;
setAvailableSize(null)
setReferenceWidth(null)
return
}
const raf = requestAnimationFrame(() => update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen])
useEffect(() => {
if (!isMobile) return;
if (isOpen) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
if (!isOpen || isMobile) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
}
}, [isOpen, isMobile]);
const referenceEl = anchorRef.current
if (!referenceEl) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y))
})
}
measure()
const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left)
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
const shouldUseDesktopFade = !desktopPreventInitialFlash
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles
useEffect(() => {
if (!isMobile) return
if (isOpen) {
bottomSheetRef.current?.present()
} else {
bottomSheetRef.current?.dismiss()
}
}, [isOpen, isMobile])
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
handleClose()
}
},
[handleClose]
);
)
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} />
),
[]
);
)
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim()
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
options,
searchQuery,
searchable,
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
)
const visibleOptions = useMemo(
() =>
buildVisibleComboboxOptions({
options,
searchQuery,
searchable,
allowCustomValue,
customValuePrefix,
customValueDescription,
customValueKind,
}),
[
allowCustomValue,
customValueDescription,
customValueKind,
customValuePrefix,
options,
searchQuery,
searchable,
]
)
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
)
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== 'above-search') {
return
}
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
);
}, [options, normalizedSearch]);
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
})
}, [effectiveOptionsPosition, isMobile])
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
searchable &&
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const visibleOptions = useMemo(() => {
const next: Array<{
id: string;
label: string;
description?: string;
}> = [];
if (showCustomOption) {
next.push({
id: sanitizedSearchValue,
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
description: customValueDescription,
});
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return
}
for (const opt of filteredOptions) {
next.push({
id: opt.id,
label: opt.label,
description: opt.description,
});
}
return next;
}, [
customValueDescription,
customValuePrefix,
filteredOptions,
sanitizedSearchValue,
showCustomOption,
]);
pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom])
useEffect(() => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (visibleOptions.length === 0) {
setActiveIndex(-1);
return;
if (!isOpen) {
return
}
pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return
}
void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
useEffect(() => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1)
return
}
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
)
if (normalizedSearch) {
setActiveIndex(0);
return;
setActiveIndex(fallbackIndex)
return
}
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
handleClose();
onSelect(id)
handleClose()
},
[handleClose, onSelect]
);
)
const handleSubmitSearch = useCallback(() => {
if (showCustomOption) {
handleSelect(sanitizedSearchValue);
handleSelect(sanitizedSearchValue)
}
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
}, [handleSelect, sanitizedSearchValue, showCustomOption])
const handleDesktopKey = useCallback(
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault()
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: visibleOptions.length,
itemCount: orderedVisibleOptions.length,
key,
})
);
return;
)
return
}
if (key === "Enter") {
if (visibleOptions.length === 0) return;
event?.preventDefault();
if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return
event?.preventDefault()
const index =
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
handleSelect(visibleOptions[index]!.id);
return;
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id)
return
}
if (key === "Escape") {
event?.preventDefault();
handleClose();
if (key === 'Escape') {
event?.preventDefault()
handleClose()
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
);
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
)
useEffect(() => {
if (!IS_WEB || !isOpen) return;
if (!IS_WEB || !isOpen) return
const handler = (event: KeyboardEvent) => {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
const key = event.key
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return
}
handleDesktopKey(key, event);
};
handleDesktopKey(key, event)
}
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
window.addEventListener('keydown', handler, true)
return () => {
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
window.removeEventListener('keydown', handler, true)
}
}, [handleDesktopKey, isOpen])
const searchInput = (
<SearchInput
@@ -445,16 +562,17 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
);
)
const optionsList = (
<>
{visibleOptions.length > 0 ? (
visibleOptions.map((opt, index) => (
{orderedVisibleOptions.length > 0 ? (
orderedVisibleOptions.map((opt, index) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
kind={opt.kind}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
@@ -464,14 +582,17 @@ export function Combobox({
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
)}
</>
);
)
const content = children ?? (
const defaultContent = (
<>
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
{searchable ? searchInput : null}
{optionsList}
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
</>
);
)
const content = children ?? defaultContent
if (isMobile) {
return (
@@ -499,33 +620,31 @@ export function Combobox({
{content}
</BottomSheetScrollView>
</BottomSheetModal>
);
)
}
if (!isOpen) return <></>;
if (!isOpen) return <></>
return (
<Modal
transparent
animationType="none"
visible={isOpen}
onRequestClose={handleClose}
>
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
testID="combobox-desktop-container"
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={[
styles.desktopContainer,
{
position: "absolute",
position: 'absolute',
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
floatingStyles,
referenceWidth === null ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === 'number'
? { maxHeight: Math.min(availableSize.height, 400) }
: null,
]}
ref={refs.setFloating}
collapsable={false}
@@ -542,27 +661,44 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === 'above-search' ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
styles.desktopScrollContent,
styles.desktopScrollContentAboveSearch,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
onContentSizeChange={handleDesktopOptionsContentSizeChange}
>
{optionsList}
</ScrollView>
) : null}
{searchable ? searchInput : null}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
{effectiveOptionsPosition === 'below-search' ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
) : null}
</>
)}
</Animated.View>
</View>
</Modal>
);
)
}
const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
@@ -580,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
comboboxItem: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -605,14 +741,19 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
marginLeft: "auto",
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
},
comboboxItemContent: {
flex: 1,
flexShrink: 1,
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: 'center',
justifyContent: 'center',
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -630,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
@@ -644,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
textAlign: 'left',
},
comboboxScrollContent: {
paddingBottom: theme.spacing[8],
@@ -655,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopBackdrop: {
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
@@ -666,18 +807,23 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: "#000",
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
overflow: 'hidden',
},
desktopScroll: {
maxHeight: 400,
flexShrink: 1,
minHeight: 0,
},
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
}));
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: 'flex-end',
},
}))

View File

@@ -0,0 +1,145 @@
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
type SegmentedControlIconRenderer = (props: {
color: string;
size: number;
}) => ReactNode;
export type SegmentedControlOption<T extends string> = {
value: T;
label: string;
icon?: SegmentedControlIconRenderer;
disabled?: boolean;
testID?: string;
};
type SegmentedControlProps<T extends string> = {
options: SegmentedControlOption<T>[];
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
style?: StyleProp<ViewStyle>;
testID?: string;
};
export function SegmentedControl<T extends string>({
options,
value,
onValueChange,
size = "md",
style,
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return (
<View style={[styles.container, style]} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: option.disabled }}
disabled={option.disabled}
testID={option.testID}
onPress={() => {
if (!option.disabled && option.value !== value) {
onValueChange(option.value);
}
}}
style={({ hovered, pressed }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
hovered && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
]}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
maxWidth: "100%",
gap: 4,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: "transparent",
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
},
segmentMd: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
segmentSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.border,
},
segmentHover: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
segmentDisabled: {
opacity: theme.opacity[50],
},
iconContainer: {
alignItems: "center",
justifyContent: "center",
},
label: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelSm: {
fontSize: theme.fontSize.xs,
},
labelMd: {
fontSize: theme.fontSize.sm,
},
labelSelected: {
color: theme.colors.foreground,
},
}));

View File

@@ -3,11 +3,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
export function VoicePanel() {
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const {
volume,
isMuted,
@@ -18,8 +18,9 @@ export function VoicePanel() {
activeServerId,
} = useVoice();
const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null;
const hostLabel = activeHost?.daemon.label ?? null;
const hostLabel = activeServerId
? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null
: null;
const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
return (

View File

@@ -0,0 +1,81 @@
const DEFAULT_MIN_HANDLE_SIZE = 36;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export type VerticalScrollbarGeometryInput = {
viewportSize: number;
contentSize: number;
offset: number;
minHandleSize?: number;
};
export type VerticalScrollbarGeometry = {
isVisible: boolean;
maxScrollOffset: number;
handleSize: number;
handleOffset: number;
maxHandleOffset: number;
};
export function computeVerticalScrollbarGeometry(
input: VerticalScrollbarGeometryInput
): VerticalScrollbarGeometry {
const viewportSize = Number.isFinite(input.viewportSize)
? Math.max(0, input.viewportSize)
: 0;
const contentSize = Number.isFinite(input.contentSize)
? Math.max(0, input.contentSize)
: 0;
const minHandleSize = Number.isFinite(input.minHandleSize)
? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE)
: DEFAULT_MIN_HANDLE_SIZE;
const maxScrollOffset = Math.max(0, contentSize - viewportSize);
if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
return {
isVisible: false,
maxScrollOffset: 0,
handleSize: 0,
handleOffset: 0,
maxHandleOffset: 0,
};
}
const rawHandleSize = (viewportSize * viewportSize) / contentSize;
const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize);
const maxHandleOffset = Math.max(0, viewportSize - handleSize);
const clampedOffset = clamp(input.offset, 0, maxScrollOffset);
const handleOffset =
maxScrollOffset > 0
? (clampedOffset / maxScrollOffset) * maxHandleOffset
: 0;
return {
isVisible: true,
maxScrollOffset,
handleSize,
handleOffset,
maxHandleOffset,
};
}
export type ScrollOffsetFromDragDeltaInput = {
startOffset: number;
dragDelta: number;
maxScrollOffset: number;
maxHandleOffset: number;
};
export function computeScrollOffsetFromDragDelta(
input: ScrollOffsetFromDragDeltaInput
): number {
if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) {
return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
}
const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset;
const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel;
return clamp(nextOffset, 0, input.maxScrollOffset);
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
describe("computeVerticalScrollbarGeometry", () => {
it("returns hidden geometry when content does not overflow", () => {
const geometry = computeVerticalScrollbarGeometry({
viewportSize: 500,
contentSize: 500,
offset: 0,
minHandleSize: 36,
});
expect(geometry).toEqual({
isVisible: false,
maxScrollOffset: 0,
handleSize: 0,
handleOffset: 0,
maxHandleOffset: 0,
});
});
it("computes visible geometry when content overflows", () => {
const geometry = computeVerticalScrollbarGeometry({
viewportSize: 500,
contentSize: 2000,
offset: 375,
minHandleSize: 36,
});
expect(geometry).toEqual({
isVisible: true,
maxScrollOffset: 1500,
handleSize: 125,
handleOffset: 93.75,
maxHandleOffset: 375,
});
});
it("clamps handle size to min and offset to bounds", () => {
const geometry = computeVerticalScrollbarGeometry({
viewportSize: 100,
contentSize: 10000,
offset: 99999,
minHandleSize: 24,
});
expect(geometry).toEqual({
isVisible: true,
maxScrollOffset: 9900,
handleSize: 24,
handleOffset: 76,
maxHandleOffset: 76,
});
});
});
describe("computeScrollOffsetFromDragDelta", () => {
it("maps drag distance proportionally to scroll offset", () => {
const nextOffset = computeScrollOffsetFromDragDelta({
startOffset: 250,
dragDelta: 50,
maxScrollOffset: 1000,
maxHandleOffset: 200,
});
expect(nextOffset).toBe(500);
});
it("clamps to scroll bounds", () => {
const nextOffset = computeScrollOffsetFromDragDelta({
startOffset: 900,
dragDelta: 1000,
maxScrollOffset: 1000,
maxHandleOffset: 200,
});
expect(nextOffset).toBe(1000);
});
});

View File

@@ -0,0 +1,451 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
Platform,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
const HANDLE_WIDTH_ACTIVE = 9;
const HANDLE_GRAB_WIDTH = 18;
const HANDLE_GRAB_VERTICAL_PADDING = 8;
const HANDLE_OPACITY_VISIBLE = 0.62;
const HANDLE_OPACITY_HOVERED = 0.78;
const HANDLE_OPACITY_DRAGGING = 0.9;
const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90;
const HANDLE_FADE_DURATION_MS = 220;
const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
function readClientY(event: any): number | null {
const value =
event?.nativeEvent?.clientY ??
event?.clientY ??
event?.nativeEvent?.pageY ??
event?.pageY;
return typeof value === "number" ? value : null;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
type ScrollbarMetrics = {
offset: number;
viewportSize: number;
contentSize: number;
};
function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
return (
Math.abs(a.offset - b.offset) <= METRICS_EPSILON &&
Math.abs(a.viewportSize - b.viewportSize) <= METRICS_EPSILON &&
Math.abs(a.contentSize - b.contentSize) <= METRICS_EPSILON
);
}
export function useWebDesktopScrollbarMetrics() {
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
}, []);
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
setMetricsIfChanged({
offset: Math.max(0, contentOffset.y),
viewportSize: Math.max(0, layoutMeasurement.height),
contentSize: Math.max(0, contentSize.height),
});
},
[setMetricsIfChanged]
);
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
const viewportSize = Math.max(0, event.nativeEvent.layout.height);
setMetrics((previous) => {
const next = { ...previous, viewportSize };
return areMetricsEqual(previous, next) ? previous : next;
});
},
[]
);
const onContentSizeChange = useCallback((_width: number, height: number) => {
const contentSize = Math.max(0, height);
setMetrics((previous) => {
const next = { ...previous, contentSize };
return areMetricsEqual(previous, next) ? previous : next;
});
}, []);
const setOffset = useCallback((offset: number) => {
const clampedOffset = Math.max(0, offset);
setMetrics((previous) => {
const next = { ...previous, offset: clampedOffset };
return areMetricsEqual(previous, next) ? previous : next;
});
}, []);
return {
...metrics,
onScroll,
onLayout,
onContentSizeChange,
setOffset,
};
}
type WebDesktopScrollbarOverlayProps = {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
};
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
onScrollToOffset,
inverted = false,
}: WebDesktopScrollbarOverlayProps) {
const { theme } = useUnistyles();
const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const geometryRef = useRef({
maxHandleOffset: 0,
maxScrollOffset: 0,
});
const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted
? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset))
: clamp(metrics.offset, 0, maxScrollOffset);
const normalizedOffsetRef = useRef(normalizedOffset);
const geometry = useMemo(
() =>
computeVerticalScrollbarGeometry({
viewportSize: metrics.viewportSize,
contentSize: metrics.contentSize,
offset: normalizedOffset,
}),
[metrics.contentSize, metrics.viewportSize, normalizedOffset]
);
useEffect(() => {
geometryRef.current = {
maxHandleOffset: geometry.maxHandleOffset,
maxScrollOffset: geometry.maxScrollOffset,
};
}, [geometry.maxHandleOffset, geometry.maxScrollOffset]);
useEffect(() => {
onScrollToOffsetRef.current = onScrollToOffset;
}, [onScrollToOffset]);
useEffect(() => {
normalizedOffsetRef.current = normalizedOffset;
}, [normalizedOffset]);
const clearScrollVisibilityTimeout = useCallback(() => {
if (scrollVisibilityTimeoutRef.current === null) {
return;
}
clearTimeout(scrollVisibilityTimeoutRef.current);
scrollVisibilityTimeoutRef.current = null;
}, []);
const clearScrollActiveTimeout = useCallback(() => {
if (scrollActiveTimeoutRef.current === null) {
return;
}
clearTimeout(scrollActiveTimeoutRef.current);
scrollActiveTimeoutRef.current = null;
}, []);
const revealScrollbarFromScroll = useCallback(() => {
setIsScrollVisible(true);
clearScrollVisibilityTimeout();
scrollVisibilityTimeoutRef.current = setTimeout(() => {
setIsScrollVisible(false);
scrollVisibilityTimeoutRef.current = null;
}, HANDLE_SCROLL_VISIBILITY_MS);
}, [clearScrollVisibilityTimeout]);
const markScrollActivity = useCallback(() => {
setIsScrollActive(true);
clearScrollActiveTimeout();
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, HANDLE_SCROLL_ACTIVE_MS);
}, [clearScrollActiveTimeout]);
useEffect(() => {
if (!enabled || !geometry.isVisible) {
setIsScrollVisible(false);
setIsScrollActive(false);
clearScrollVisibilityTimeout();
clearScrollActiveTimeout();
lastObservedOffsetRef.current = null;
return;
}
const previousOffset = lastObservedOffsetRef.current;
lastObservedOffsetRef.current = normalizedOffset;
if (previousOffset === null) {
return;
}
if (Math.abs(normalizedOffset - previousOffset) <= METRICS_EPSILON) {
return;
}
revealScrollbarFromScroll();
markScrollActivity();
}, [
clearScrollActiveTimeout,
clearScrollVisibilityTimeout,
enabled,
geometry.isVisible,
markScrollActivity,
normalizedOffset,
revealScrollbarFromScroll,
]);
useEffect(
() => () => {
clearScrollActiveTimeout();
clearScrollVisibilityTimeout();
},
[clearScrollActiveTimeout, clearScrollVisibilityTimeout]
);
const applyDragDelta = useCallback(
(dragDelta: number) => {
const currentGeometry = geometryRef.current;
const nextNormalizedOffset = computeScrollOffsetFromDragDelta({
startOffset: dragStartOffsetRef.current,
dragDelta,
maxScrollOffset: currentGeometry.maxScrollOffset,
maxHandleOffset: currentGeometry.maxHandleOffset,
});
const nextOffset = inverted
? currentGeometry.maxScrollOffset - nextNormalizedOffset
: nextNormalizedOffset;
onScrollToOffsetRef.current(nextOffset);
},
[inverted]
);
const panResponder = useMemo(() => {
if (isWeb) {
return null;
}
return PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
dragStartOffsetRef.current = normalizedOffsetRef.current;
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
applyDragDelta(gestureState.dy);
},
onPanResponderRelease: () => {
setIsDragging(false);
},
onPanResponderTerminate: () => {
setIsDragging(false);
},
});
}, [applyDragDelta, isWeb]);
const startWebDrag = useCallback(
(event: any) => {
if (!isWeb) {
return;
}
const clientY = readClientY(event);
if (clientY === null) {
return;
}
event?.preventDefault?.();
event?.stopPropagation?.();
event?.nativeEvent?.preventDefault?.();
dragStartOffsetRef.current = normalizedOffsetRef.current;
dragStartClientYRef.current = clientY;
setIsDragging(true);
},
[isWeb]
);
const handleGrabHoverIn = useCallback(() => {
if (!isScrollVisible && !isDragging) {
return;
}
setIsHandleHovered(true);
}, [isDragging, isScrollVisible]);
const handleGrabHoverOut = useCallback(() => {
setIsHandleHovered(false);
}, []);
useEffect(() => {
if (!isWeb || !isDragging) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragDelta = event.clientY - dragStartClientYRef.current;
applyDragDelta(dragDelta);
};
const stopDragging = () => {
setIsDragging(false);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [applyDragDelta, isDragging, isWeb]);
if (!enabled || !geometry.isVisible) {
return null;
}
const handleVisible = isDragging || isScrollVisible || isHandleHovered;
const handleOpacity = isDragging
? HANDLE_OPACITY_DRAGGING
: isHandleHovered
? HANDLE_OPACITY_HOVERED
: isScrollVisible
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#18181c";
const handleColor = isDark
? theme.colors.palette.zinc[500]
: theme.colors.palette.zinc[700];
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING);
const thumbRegionHeight = Math.min(
metrics.viewportSize - thumbRegionOffset,
geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
return (
<View style={styles.overlay} pointerEvents="box-none">
<View
style={[
styles.thumbRegion,
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
isWeb &&
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as any),
]}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(isWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as any)
: null)}
>
<View
style={[
styles.handle,
{
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
isWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction:
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as any),
]}
pointerEvents="none"
/>
</View>
</View>
);
}
const styles = StyleSheet.create(() => ({
overlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
},
handle: {
width: HANDLE_WIDTH_IDLE,
borderRadius: 999,
alignSelf: "center",
},
thumbRegion: {
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
},
}));

View File

@@ -1,4 +1,5 @@
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
export const FOOTER_HEIGHT = 75;
@@ -7,6 +8,7 @@ export const FOOTER_HEIGHT = 75;
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = 48;
export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
@@ -19,21 +21,20 @@ export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
// Check if running in Tauri desktop app (any OS)
function isTauri(): boolean {
if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false;
return "__TAURI__" in window;
return getTauri() !== null;
}
// Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean {
if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false;
if (!("__TAURI__" in window)) return false;
if (getTauri() === null) return false;
// Check for macOS via user agent
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}
// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later)
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
let _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null;

View File

@@ -1,187 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" };
export type ConnectionState =
| { status: "idle"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
| { status: "connecting"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "online"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
| { status: "offline"; activeConnection: ActiveConnection | null; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "error"; activeConnection: ActiveConnection | null; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
export type ConnectionStatus = ConnectionState["status"];
type ConnectionStateUpdate =
| { status: "idle" }
| { status: "connecting"; activeConnection?: ActiveConnection | null; lastOnlineAt?: string | null }
| { status: "online"; activeConnection?: ActiveConnection | null; lastOnlineAt: string; agentListReady?: boolean }
| { status: "offline"; activeConnection?: ActiveConnection | null; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; activeConnection?: ActiveConnection | null; lastError: string; lastOnlineAt?: string | null };
export type DaemonConnectionRecord = {
daemon: HostProfile;
} & ConnectionState;
interface DaemonConnectionsContextValue {
connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean;
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
function createDefaultConnectionState(): ConnectionState {
return {
status: "idle",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
agentListReady: false,
hasEverReceivedAgentList: false,
};
}
function resolveNextConnectionState(
existing: ConnectionState,
update: ConnectionStateUpdate
): ConnectionState {
switch (update.status) {
case "idle":
return {
status: "idle",
activeConnection: existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: false,
};
case "connecting":
return {
status: "connecting",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "online":
const currentAgentListReady = update.agentListReady ?? (existing.status === "online" ? existing.agentListReady : false);
return {
status: "online",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt,
agentListReady: currentAgentListReady,
hasEverReceivedAgentList:
currentAgentListReady || existing.hasEverReceivedAgentList || false,
};
case "offline":
return {
status: "offline",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError ?? null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "error":
return {
status: "error",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
}
}
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) {
const severity: "info" | "warn" = next.status === "error" ? "warn" : "info";
const logger = severity === "warn" ? console.warn : console.info;
const logPayload = {
event: "daemon_connection_state",
serverId: daemon.serverId,
label: daemon.label,
from: previous.status,
to: next.status,
lastError: next.lastError ?? null,
lastOnlineAt: next.lastOnlineAt ?? null,
timestamp: new Date().toISOString(),
severity,
};
logger("[DaemonConnection]", logPayload);
}
export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext);
if (!ctx) {
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
}
return ctx;
}
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
// Ensure connection states stay in sync with registry entries
useEffect(() => {
setConnectionStates((prev) => {
const next = new Map<string, DaemonConnectionRecord>();
for (const daemon of daemons) {
const existing = prev.get(daemon.serverId);
next.set(daemon.serverId, {
daemon,
...(existing ?? createDefaultConnectionState()),
});
}
return next;
});
}, [daemons]);
const updateConnectionStatus = useCallback(
(serverId: string, update: ConnectionStateUpdate) => {
setConnectionStates((prev) => {
const existing = prev.get(serverId);
if (!existing) {
return prev;
}
const nextState = resolveNextConnectionState(existing, update);
const hasChanged =
existing.status !== nextState.status ||
existing.lastError !== nextState.lastError ||
existing.lastOnlineAt !== nextState.lastOnlineAt;
if (hasChanged) {
logConnectionLifecycle(existing.daemon, existing, nextState);
}
const next = new Map(prev);
next.set(serverId, { daemon: existing.daemon, ...nextState });
return next;
});
},
[]
);
const value: DaemonConnectionsContextValue = {
connectionStates,
isLoading: registryLoading,
updateConnectionStatus,
};
return (
<DaemonConnectionsContext.Provider value={value}>
{children}
</DaemonConnectionsContext.Provider>
);
}

View File

@@ -12,6 +12,17 @@ import { usePanelStore } from "@/stores/panel-store";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerAnimation(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerAnimation] ${event}`, details);
}
interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>;
@@ -50,13 +61,28 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (prevIsOpen.current === isOpen) {
return;
}
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
logExplorerAnimation("sync-skipped-during-gesture", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
});
return;
}
logExplorerAnimation("sync-state-change", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
windowWidth,
});
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
@@ -76,7 +102,15 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
easing: ANIMATION_EASING,
});
}
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {
"worklet";

View File

@@ -1,10 +1,10 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { AppState, Platform } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import {
applyStreamEvent,
generateMessageId,
@@ -14,27 +14,36 @@ import {
} from "@/types/stream";
import type {
ActivityLogPayload,
AgentSnapshotPayload,
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import {
buildAgentAttentionNotificationPayload,
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@server/shared/agent-attention-notification";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
import type { ActiveConnection } from "./daemon-connections-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
useSessionStore,
type Agent,
type SessionState,
type DaemonConnectionSnapshot,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -49,59 +58,8 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
const derivePendingPermissionKey = (
agentId: string,
request: AgentPermissionRequest
) => {
const fallbackId =
request.id ||
(typeof request.metadata?.id === "string"
? request.metadata.id
: undefined) ||
request.name ||
request.title ||
`${request.kind}:${JSON.stringify(
request.input ?? request.metadata ?? {}
)}`;
return `${agentId}:${fallbackId}`;
};
const NOTIFICATION_PREVIEW_LIMIT = 220;
const HISTORY_STALE_AFTER_MS = 60_000;
const normalizeNotificationText = (text: string): string =>
text.replace(/\s+/g, " ").trim();
const truncateNotificationText = (text: string, limit: number): string => {
if (text.length <= limit) {
return text;
}
const trimmed = text.slice(0, Math.max(0, limit - 3)).trimEnd();
return trimmed.length > 0 ? `${trimmed}...` : text.slice(0, limit);
};
const buildNotificationPreview = (
text: string | null | undefined
): string | null => {
if (!text) {
return null;
}
const normalized = normalizeNotificationText(text);
if (!normalized) {
return null;
}
return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
};
const safeStringify = (value: unknown): string | null => {
try {
return JSON.stringify(value);
} catch {
return null;
}
};
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -112,24 +70,15 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
return null;
};
const mapConnectionState = (
state: ConnectionState,
lastError: string | null
): DaemonConnectionSnapshot => ({
isConnected: state.status === "connected",
isConnecting: state.status === "connecting",
lastError: state.status === "disconnected" ? state.reason ?? lastError : null,
});
const getLatestPermissionRequest = (
session: SessionState | undefined,
agentId: string
): AgentPermissionRequest | null => {
): NotificationPermissionRequest | null => {
if (!session) {
return null;
}
let latest: AgentPermissionRequest | null = null;
let latest: NotificationPermissionRequest | null = null;
for (const pending of session.pendingPermissions.values()) {
if (pending.agentId === agentId) {
latest = pending.request;
@@ -141,46 +90,12 @@ const getLatestPermissionRequest = (
const agentPending = session.agents.get(agentId)?.pendingPermissions;
if (agentPending && agentPending.length > 0) {
return agentPending[agentPending.length - 1] as AgentPermissionRequest;
return agentPending[agentPending.length - 1] as NotificationPermissionRequest;
}
return null;
};
const buildPermissionDetails = (
request: AgentPermissionRequest | null
): string | null => {
if (!request) {
return null;
}
const title = request.title?.trim();
const description = request.description?.trim();
const details: string[] = [];
if (title) {
details.push(title);
}
if (description && description !== title) {
details.push(description);
}
if (details.length > 0) {
return details.join(" - ");
}
const inputPreview = request.input ? safeStringify(request.input) : null;
if (inputPreview) {
return inputPreview;
}
const metadataPreview = request.metadata
? safeStringify(request.metadata)
: null;
if (metadataPreview) {
return metadataPreview;
}
return request.name?.trim() || request.kind;
};
type FileExplorerPayload = Extract<
SessionOutboundMessage,
{ type: "file_explorer_response" }
@@ -199,51 +114,6 @@ type AgentUpdatePayload = Extract<
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id;
function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string
) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt
? new Date(snapshot.lastUserMessageAt)
: null;
const attentionTimestamp = snapshot.attentionTimestamp
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt
? new Date(snapshot.archivedAt)
: null;
return {
serverId,
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt,
capabilities: snapshot.capabilities,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes ?? [],
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
labels: snapshot.labels,
projectPlacement: null,
};
}
const createExplorerState = () => ({
directories: new Map(),
files: new Map(),
@@ -265,28 +135,41 @@ const pushHistory = (history: string[], path: string): string[] => {
return [...normalizedHistory, path];
};
interface SessionProviderProps {
interface SessionProviderSharedProps {
children: ReactNode;
serverUrl: string;
serverId: string;
activeConnection: ActiveConnection | null;
daemonPublicKeyB64?: string;
}
interface SessionProviderClientProps extends SessionProviderSharedProps {
client: DaemonClient;
}
export type SessionProviderProps = SessionProviderClientProps;
function SessionProviderWithClient({
children,
serverId,
client,
}: SessionProviderClientProps) {
return (
<SessionProviderInternal serverId={serverId} client={client}>
{children}
</SessionProviderInternal>
);
}
// SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider({
export function SessionProvider(props: SessionProviderProps) {
return <SessionProviderWithClient {...props} />;
}
function SessionProviderInternal({
children,
serverUrl,
serverId,
activeConnection,
daemonPublicKeyB64,
}: SessionProviderProps) {
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { updateConnectionStatus } = useDaemonConnections();
client,
}: SessionProviderClientProps) {
const queryClient = useQueryClient();
const { isConnected } = useHostRuntimeSession(serverId);
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -302,6 +185,9 @@ export function SessionProvider({
const setAgentStreamHead = useSessionStore(
(state) => state.setAgentStreamHead
);
const setAgentStreamState = useSessionStore(
(state) => state.setAgentStreamState
);
const clearAgentStreamHead = useSessionStore(
(state) => state.clearAgentStreamHead
);
@@ -324,6 +210,9 @@ export function SessionProvider({
const setAgentLastActivity = useSessionStore(
(state) => state.setAgentLastActivity
);
const flushAgentLastActivity = useSessionStore(
(state) => state.flushAgentLastActivity
);
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
@@ -331,15 +220,15 @@ export function SessionProvider({
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
const updateSessionConnection = useSessionStore(
(state) => state.updateSessionConnection
);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const sessionAgents = useSessionStore(
(state) => state.sessions[serverId]?.agents
);
const handleAppResumed = useCallback(
(awayMs: number) => {
@@ -372,12 +261,10 @@ export function SessionProvider({
| ((
agentId: string,
message: string,
images?: Array<{ uri: string; mimeType?: string }>
images?: AttachmentMetadata[]
) => Promise<void>)
| null
>(null);
const hasBootstrappedAgentUpdatesRef = useRef(false);
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
@@ -397,11 +284,24 @@ export function SessionProvider({
};
}, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const notifyAgentAttention = useCallback(
(params: {
agentId: string;
reason: "finished" | "error" | "permission";
timestamp: string;
notification?: AgentAttentionNotificationPayload;
}) => {
const appState = appStateRef.current;
const session = useSessionStore.getState().sessions[serverId];
@@ -412,6 +312,15 @@ export function SessionProvider({
const isActive = appState ? appState === "active" : true;
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
if (!isAwayFromAgent) {
console.log(
"[OSNotifications] Skipping attention notification: user already focused on agent",
{
agentId: params.agentId,
reason: params.reason,
appState,
focusedAgentId,
}
);
return;
}
@@ -422,43 +331,33 @@ export function SessionProvider({
}
attentionNotifiedRef.current.set(params.agentId, timestampMs);
const title =
params.reason === "permission"
? "Agent needs permission"
: "Agent finished";
let preview: string | null = null;
const head = session?.agentStreamHead.get(params.agentId) ?? [];
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
const assistantMessage =
findLatestAssistantMessageText(head) ??
findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(
session,
params.agentId
);
if (params.reason === "finished") {
const head = session?.agentStreamHead.get(params.agentId) ?? [];
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
const lastMessage =
findLatestAssistantMessageText(head) ??
findLatestAssistantMessageText(tail);
preview = buildNotificationPreview(lastMessage);
} else if (params.reason === "permission") {
const permissionRequest = getLatestPermissionRequest(
session,
params.agentId
);
preview = buildNotificationPreview(
buildPermissionDetails(permissionRequest)
);
}
const body =
preview ??
(params.reason === "permission"
? "Permission requested."
: "Finished working.");
const notification =
params.notification ??
buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId,
agentId: params.agentId,
assistantMessage: params.reason === "finished" ? assistantMessage : null,
permissionRequest:
params.reason === "permission"
? permissionRequest
: null,
});
void sendOsNotification({
title,
body,
data: {
agentId: params.agentId,
serverId,
reason: params.reason,
},
title: notification.title,
body: notification.body,
data: notification.data,
});
},
[serverId]
@@ -473,26 +372,6 @@ export function SessionProvider({
}
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
useEffect(() => {
const unsubscribe = client.subscribeConnectionStatus((state) => {
setConnectionSnapshot(mapConnectionState(state, client.lastError));
});
return unsubscribe;
}, [client]);
const wasConnectedRef = useRef(client.isConnected);
useEffect(() => {
const wasConnected = wasConnectedRef.current;
if (!wasConnected && connectionSnapshot.isConnected) {
bumpHistorySyncGeneration(serverId);
}
wasConnectedRef.current = connectionSnapshot.isConnected;
}, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]);
useEffect(() => {
updateSessionConnection(serverId, connectionSnapshot);
}, [serverId, connectionSnapshot, updateSessionConnection]);
// Initialize session in store
useEffect(() => {
initializeSession(serverId, client, audioPlayer);
@@ -502,48 +381,14 @@ export function SessionProvider({
updateSessionClient(serverId, client);
}, [serverId, client, updateSessionClient]);
// Connection status tracking
useEffect(() => {
if (connectionSnapshot.isConnected) {
updateConnectionStatus(serverId, {
status: "online",
activeConnection,
lastOnlineAt: new Date().toISOString(),
});
return;
}
if (connectionSnapshot.isConnecting) {
updateConnectionStatus(serverId, { status: "connecting", activeConnection });
return;
}
if (connectionSnapshot.lastError) {
updateConnectionStatus(serverId, {
status: "error",
activeConnection,
lastError: connectionSnapshot.lastError,
});
return;
}
updateConnectionStatus(serverId, { status: "offline", activeConnection });
}, [
serverId,
updateConnectionStatus,
connectionSnapshot.isConnected,
connectionSnapshot.isConnecting,
connectionSnapshot.lastError,
activeConnection,
]);
// If the client drops mid-initialization, clear pending flags
useEffect(() => {
if (!connectionSnapshot.isConnected) {
if (!isConnected) {
flushAgentLastActivity();
pendingAgentUpdatesRef.current.clear();
setInitializingAgents(serverId, new Map());
}
}, [serverId, connectionSnapshot.isConnected, setInitializingAgents]);
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => {
@@ -551,6 +396,7 @@ export function SessionProvider({
const agentId = update.agentId;
previousAgentStatusRef.current.delete(agentId);
pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
@@ -593,17 +439,18 @@ export function SessionProvider({
next.delete(agentId);
return next;
});
return;
}
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = {
...normalizeAgentSnapshot(update.agent, serverId),
projectPlacement: update.project,
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
};
console.log("[Session] Agent update:", agent.id, agent.status);
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
@@ -614,19 +461,65 @@ export function SessionProvider({
return next;
});
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
// Update agentLastActivity slice (top-level)
setAgentLastActivity(agent.id, agent.lastActivityAt);
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of Array.from(next.entries())) {
const existingKeysForAgent: string[] = [];
for (const [key, pending] of prev.entries()) {
if (pending.agentId === agent.id) {
next.delete(key);
existingKeysForAgent.push(key);
}
}
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
next.set(key, { key, agentId: agent.id, request });
const nextEntries = agent.pendingPermissions.map((request) => ({
key: derivePendingPermissionKey(agent.id, request),
agentId: agent.id,
request,
}));
let changed = existingKeysForAgent.length !== nextEntries.length;
if (!changed) {
const existingKeySet = new Set(existingKeysForAgent);
for (const entry of nextEntries) {
const existing = prev.get(entry.key);
if (!existingKeySet.has(entry.key) || !existing) {
changed = true;
break;
}
const currentRequest = existing.request;
if (
currentRequest.id !== entry.request.id ||
currentRequest.kind !== entry.request.kind ||
currentRequest.name !== entry.request.name ||
currentRequest.title !== entry.request.title ||
currentRequest.description !== entry.request.description
) {
changed = true;
break;
}
}
}
if (!changed) {
return prev;
}
const next = new Map(prev);
for (const key of existingKeysForAgent) {
next.delete(key);
}
for (const entry of nextEntries) {
next.set(entry.key, entry);
}
return next;
});
@@ -657,6 +550,7 @@ export function SessionProvider({
previousAgentStatusRef.current.set(agent.id, agent.status);
},
[
queryClient,
serverId,
setAgents,
setAgentLastActivity,
@@ -666,12 +560,6 @@ export function SessionProvider({
]
);
useEffect(() => {
return () => {
updateConnectionStatus(serverId, { status: "offline", activeConnection, lastError: null });
};
}, [serverId, updateConnectionStatus, activeConnection]);
const updateExplorerState = useCallback(
(agentId: string, updater: (state: any) => any) => {
setFileExplorer(serverId, (prev) => {
@@ -848,44 +736,11 @@ export function SessionProvider({
);
useEffect(() => {
if (!connectionSnapshot.isConnected) {
hasBootstrappedAgentUpdatesRef.current = false;
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
if (subscriptionId && client) {
try {
client.unsubscribeAgentUpdates(subscriptionId);
} catch {
// no-op
}
}
agentUpdatesSubscriptionIdRef.current = null;
if (isConnected) {
return;
}
if (hasBootstrappedAgentUpdatesRef.current) {
return;
}
hasBootstrappedAgentUpdatesRef.current = true;
try {
if (!agentUpdatesSubscriptionIdRef.current) {
agentUpdatesSubscriptionIdRef.current = client.subscribeAgentUpdates({
subscriptionId: `app:${serverId}`,
filter: { labels: { ui: "true" } },
});
}
} catch (err) {
console.error("[Session] subscribeAgentUpdates failed", { serverId, err });
}
// Session bootstrap is now fully event-driven for agent lists.
setInitializingAgents(serverId, new Map());
setHasHydratedAgents(serverId, true);
updateConnectionStatus(serverId, {
status: "online",
lastOnlineAt: new Date().toISOString(),
agentListReady: true,
});
}, [connectionSnapshot.isConnected, client, serverId, setHasHydratedAgents, updateConnectionStatus]);
pendingAgentUpdatesRef.current.clear();
}, [isConnected]);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
@@ -919,6 +774,7 @@ export function SessionProvider({
agentId,
reason: event.reason,
timestamp: event.timestamp,
notification: event.notification,
});
}
}
@@ -933,23 +789,10 @@ export function SessionProvider({
timestamp: parsedTimestamp,
});
if (changedTail) {
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, tail);
return next;
});
}
if (changedHead) {
setAgentStreamHead(serverId, (prev) => {
const next = new Map(prev);
if (head.length > 0) {
next.set(agentId, head);
} else {
next.delete(agentId);
}
return next;
if (changedTail || changedHead) {
setAgentStreamState(serverId, agentId, {
...(changedTail ? { tail } : {}),
...(changedHead ? { head } : {}),
});
}
@@ -994,6 +837,7 @@ export function SessionProvider({
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities }
: {}),
@@ -1287,6 +1131,7 @@ export function SessionProvider({
const { agentId } = message.payload;
console.log("[Session] Agent deleted:", agentId);
pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
@@ -1329,7 +1174,9 @@ export function SessionProvider({
});
// Remove draft input
clearDraftInput(agentId);
clearDraftInput({
draftKey: buildDraftStoreKey({ serverId, agentId }),
});
setPendingPermissions(serverId, (prev) => {
let changed = false;
@@ -1368,6 +1215,7 @@ export function SessionProvider({
}
const { agentId, archivedAt } = message.payload;
console.log("[Session] Agent archived:", agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
const existing = prev.get(agentId);
@@ -1400,12 +1248,14 @@ export function SessionProvider({
}, [
client,
audioPlayer,
queryClient,
serverId,
setIsPlayingAudio,
setMessages,
setCurrentAssistantMessage,
setAgentStreamTail,
setAgentStreamHead,
setAgentStreamState,
clearAgentStreamHead,
setAgentTimelineCursor,
setInitializingAgents,
@@ -1414,7 +1264,6 @@ export function SessionProvider({
setPendingPermissions,
setFileExplorer,
setHasHydratedAgents,
updateConnectionStatus,
clearDraftInput,
notifyAgentAttention,
applyAgentUpdatePayload,
@@ -1425,7 +1274,7 @@ export function SessionProvider({
async (
agentId: string,
message: string,
images?: Array<{ uri: string; mimeType?: string }>
images?: AttachmentMetadata[]
) => {
const messageId = generateMessageId();
const userMessage: StreamItem = {
@@ -1540,7 +1389,7 @@ export function SessionProvider({
}: {
config: any;
initialPrompt: string;
images?: Array<{ uri: string; mimeType?: string }>;
images?: AttachmentMetadata[];
git?: any;
worktreeName?: string;
requestId?: string;

View File

@@ -19,8 +19,13 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
type ToastVariant = "default" | "success" | "error";
@@ -209,6 +214,11 @@ function ToastViewport({
return null;
}
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const icon =
toast.icon ?? (
toast.variant === "success" ? (
@@ -227,7 +237,8 @@ function ToastViewport({
toast.variant === "success" ? styles.toastSuccess : null,
toast.variant === "error" ? styles.toastError : null,
{
marginTop: theme.spacing[2] + insets.top,
marginTop:
insets.top + headerTopPadding + headerHeight + theme.spacing[2],
opacity,
transform: [{ translateY }],
},

View File

@@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
import type { SessionState } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
@@ -45,6 +46,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
const getSession = useSessionStore((state) => state.getSession);
const [activeServerId, setActiveServerId] = useState<string | null>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession(
activeServerId ?? ""
);
const activeSession = useSessionStore(
useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => {
@@ -274,8 +278,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
}, [isVoiceMode, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]);
useEffect(() => {
const connected = activeSession?.connection.isConnected ?? false;
const client = activeSession?.client ?? null;
const connected = activeRuntimeConnected;
const client = runtimeClient;
if (!connected) {
voiceTransportReadyRef.current = false;
}
@@ -313,7 +317,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
}
wasVoiceSocketConnectedRef.current = connected;
}, [activeAgentId, activeServerId, activeSession?.client, activeSession?.connection.isConnected, isVoiceMode]);
}, [activeAgentId, activeRuntimeConnected, activeServerId, isVoiceMode, runtimeClient]);
const isPlayingAudio = activeSession?.isPlayingAudio ?? false;

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createDesktopAttachmentStore } from "./desktop-attachment-store";
const {
copyDesktopAttachmentFileMock,
writeDesktopAttachmentBase64Mock,
deleteDesktopAttachmentFileMock,
garbageCollectDesktopAttachmentFilesMock,
readDesktopFileBase64Mock,
resolveDesktopPreviewUrlMock,
releaseDesktopPreviewUrlMock,
} = vi.hoisted(() => ({
copyDesktopAttachmentFileMock: vi.fn(async () => ({
path: "/managed/att_1.png",
byteSize: 4,
})),
writeDesktopAttachmentBase64Mock: vi.fn(async () => ({
path: "/managed/att_2.png",
byteSize: 4,
})),
deleteDesktopAttachmentFileMock: vi.fn(async () => true),
garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"),
releaseDesktopPreviewUrlMock: vi.fn(async () => {}),
}));
vi.mock("./desktop-file-commands", () => ({
copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
}));
vi.mock("./desktop-preview-url", () => ({
readDesktopFileBase64: readDesktopFileBase64Mock,
resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock,
releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock,
}));
describe("desktop attachment store", () => {
beforeEach(() => {
copyDesktopAttachmentFileMock.mockClear();
writeDesktopAttachmentBase64Mock.mockClear();
deleteDesktopAttachmentFileMock.mockClear();
garbageCollectDesktopAttachmentFilesMock.mockClear();
readDesktopFileBase64Mock.mockClear();
resolveDesktopPreviewUrlMock.mockClear();
releaseDesktopPreviewUrlMock.mockClear();
});
it("saves dropped file paths as desktop-file metadata", async () => {
const store = createDesktopAttachmentStore();
const attachment = await store.save({
id: "att_1",
mimeType: "image/png",
source: {
kind: "file_uri",
uri: "file:///Users/test/Desktop/image.png",
},
});
expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({
attachmentId: "att_1",
sourcePath: "/Users/test/Desktop/image.png",
extension: ".png",
});
expect(attachment.storageType).toBe("desktop-file");
expect(attachment.storageKey).toBe("/managed/att_1.png");
});
it("saves blob/data-url sources via desktop filesystem writes", async () => {
const store = createDesktopAttachmentStore();
await store.save({
id: "att_2",
source: {
kind: "data_url",
dataUrl: "data:image/png;base64,AAECAw==",
},
});
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
attachmentId: "att_2",
base64: "AAECAw==",
extension: ".png",
});
});
it("delegates encode/preview/delete/gc to desktop command path", async () => {
const store = createDesktopAttachmentStore();
const attachment = {
id: "att_3",
mimeType: "image/jpeg",
storageType: "desktop-file" as const,
storageKey: "/managed/att_3.jpg",
createdAt: Date.now(),
};
await store.encodeBase64({ attachment });
await store.resolvePreviewUrl({ attachment });
await store.releasePreviewUrl?.({ attachment, url: "blob:test" });
await store.delete({ attachment });
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
path: "/managed/att_3.jpg",
});
expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({
referencedIds: ["att_3"],
});
});
});

View File

@@ -0,0 +1,207 @@
import type { AttachmentMetadata, AttachmentStore } from "@/attachments/types";
import {
blobToBase64,
fileUriToPath,
generateAttachmentId,
getFileExtensionFromName,
normalizeMimeType,
parseDataUrl,
} from "@/attachments/utils";
import {
copyDesktopAttachmentFile,
deleteDesktopAttachmentFile,
garbageCollectDesktopAttachmentFiles,
writeDesktopAttachmentBase64,
} from "@/desktop/attachments/desktop-file-commands";
import {
readDesktopFileBase64,
releaseDesktopPreviewUrl,
resolveDesktopPreviewUrl,
} from "@/desktop/attachments/desktop-preview-url";
const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/avif": ".avif",
"image/heic": ".heic",
"image/heif": ".heif",
"image/tiff": ".tiff",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
};
function inferFileNameFromPath(path: string): string | null {
const normalizedPath = path.replace(/\\/g, "/");
const parts = normalizedPath.split("/");
const lastPart = parts[parts.length - 1];
return lastPart && lastPart.length > 0 ? lastPart : null;
}
function extensionForAttachment(input: {
fileName?: string | null;
sourcePath?: string | null;
mimeType: string;
}): string {
const fromName = getFileExtensionFromName(input.fileName);
if (fromName) {
return fromName;
}
const fromSourcePath = getFileExtensionFromName(input.sourcePath);
if (fromSourcePath) {
return fromSourcePath;
}
return IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType] ?? ".img";
}
function toDesktopMetadata(input: {
id: string;
mimeType: string;
storagePath: string;
byteSize: number;
fileName?: string | null;
}): AttachmentMetadata {
return {
id: input.id,
mimeType: input.mimeType,
storageType: "desktop-file",
storageKey: input.storagePath,
fileName: input.fileName ?? null,
byteSize: input.byteSize,
createdAt: Date.now(),
};
}
async function saveDesktopAttachmentFromFileUri(input: {
id: string;
uri: string;
mimeType?: string;
fileName?: string | null;
}): Promise<AttachmentMetadata> {
const sourcePath = fileUriToPath(input.uri);
const fileName = input.fileName ?? inferFileNameFromPath(sourcePath);
const mimeType = normalizeMimeType(input.mimeType);
const extension = extensionForAttachment({
fileName,
sourcePath,
mimeType,
});
const result = await copyDesktopAttachmentFile({
attachmentId: input.id,
sourcePath,
extension,
});
return toDesktopMetadata({
id: input.id,
mimeType,
storagePath: result.path,
byteSize: result.byteSize,
fileName,
});
}
async function saveDesktopAttachmentFromBase64(input: {
id: string;
base64: string;
mimeType: string;
fileName?: string | null;
}): Promise<AttachmentMetadata> {
const extension = extensionForAttachment({
fileName: input.fileName,
mimeType: input.mimeType,
});
const result = await writeDesktopAttachmentBase64({
attachmentId: input.id,
base64: input.base64,
extension,
});
return toDesktopMetadata({
id: input.id,
mimeType: input.mimeType,
storagePath: result.path,
byteSize: result.byteSize,
fileName: input.fileName,
});
}
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
if (attachment.storageType !== "desktop-file") {
throw new Error(
`Unsupported desktop attachment storage type '${attachment.storageType}'.`
);
}
}
export function createDesktopAttachmentStore(): AttachmentStore {
return {
storageType: "desktop-file",
async save(input): Promise<AttachmentMetadata> {
const id = input.id ?? generateAttachmentId();
const fileName = input.fileName ?? null;
if (input.source.kind === "file_uri") {
return await saveDesktopAttachmentFromFileUri({
id,
uri: input.source.uri,
mimeType: input.mimeType,
fileName,
});
}
if (input.source.kind === "data_url") {
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType);
return await saveDesktopAttachmentFromBase64({
id,
base64: parsed.base64,
mimeType,
fileName,
});
}
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
const base64 = await blobToBase64(input.source.blob);
return await saveDesktopAttachmentFromBase64({
id,
base64,
mimeType,
fileName,
});
},
async encodeBase64({ attachment }): Promise<string> {
assertDesktopAttachment(attachment);
return await readDesktopFileBase64(attachment.storageKey);
},
async resolvePreviewUrl({ attachment }): Promise<string> {
assertDesktopAttachment(attachment);
return await resolveDesktopPreviewUrl(attachment);
},
async releasePreviewUrl({ attachment, url }): Promise<void> {
assertDesktopAttachment(attachment);
await releaseDesktopPreviewUrl({ url });
},
async delete({ attachment }): Promise<void> {
assertDesktopAttachment(attachment);
await deleteDesktopAttachmentFile({ path: attachment.storageKey });
},
async garbageCollect({ referencedIds }): Promise<void> {
await garbageCollectDesktopAttachmentFiles({
referencedIds: Array.from(referencedIds),
});
},
};
}

View File

@@ -0,0 +1,49 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
interface AttachmentFileResult {
path: string;
byteSize: number;
}
export async function writeDesktopAttachmentBase64(input: {
attachmentId: string;
base64: string;
extension?: string | null;
}): Promise<AttachmentFileResult> {
return await invokeDesktopCommand<AttachmentFileResult>(
"write_attachment_base64",
{
attachmentId: input.attachmentId,
base64: input.base64,
extension: input.extension ?? null,
}
);
}
export async function copyDesktopAttachmentFile(input: {
attachmentId: string;
sourcePath: string;
extension?: string | null;
}): Promise<AttachmentFileResult> {
return await invokeDesktopCommand<AttachmentFileResult>("copy_attachment_file", {
attachmentId: input.attachmentId,
sourcePath: input.sourcePath,
extension: input.extension ?? null,
});
}
export async function deleteDesktopAttachmentFile(input: {
path: string;
}): Promise<boolean> {
return await invokeDesktopCommand<boolean>("delete_attachment_file", {
path: input.path,
});
}
export async function garbageCollectDesktopAttachmentFiles(input: {
referencedIds: readonly string[];
}): Promise<number> {
return await invokeDesktopCommand<number>("garbage_collect_attachment_files", {
referencedIds: [...input.referencedIds],
});
}

View File

@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
}));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));
import {
__desktopPreviewUrlTestUtils,
releaseDesktopPreviewUrl,
resolveDesktopPreviewUrl,
} from "./desktop-preview-url";
describe("desktop preview URLs", () => {
const createObjectURL = vi.fn(() => "blob:desktop-preview-1");
const revokeObjectURL = vi.fn();
beforeEach(() => {
vi.stubGlobal("URL", {
createObjectURL,
revokeObjectURL,
});
});
afterEach(() => {
createObjectURL.mockClear();
revokeObjectURL.mockClear();
invokeDesktopCommandMock.mockClear();
__desktopPreviewUrlTestUtils.clearActiveObjectUrls();
vi.unstubAllGlobals();
});
it("resolves renderer-safe blob URLs for desktop attachments", async () => {
const url = await resolveDesktopPreviewUrl({
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: Date.now(),
});
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", {
path: "/tmp/att-1.png",
});
expect(url).toBe("blob:desktop-preview-1");
expect(url.startsWith("asset://")).toBe(false);
expect(createObjectURL).toHaveBeenCalledTimes(1);
});
it("releases blob URLs via URL.revokeObjectURL", async () => {
const url = await resolveDesktopPreviewUrl({
id: "att-2",
mimeType: "image/jpeg",
storageType: "desktop-file",
storageKey: "/tmp/att-2.jpg",
createdAt: Date.now(),
});
await releaseDesktopPreviewUrl({ url });
expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1");
});
});

View File

@@ -0,0 +1,61 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
const activeObjectUrls = new Set<string>();
export async function readDesktopFileBase64(pathOrUri: string): Promise<string> {
return await invokeDesktopCommand<string>("read_file_base64", {
path: fileUriToPath(pathOrUri),
});
}
export async function resolveDesktopPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
const base64 = await readDesktopFileBase64(attachment.storageKey);
if (
typeof URL !== "undefined" &&
typeof URL.createObjectURL === "function"
) {
const bytes = base64ToUint8Array(base64);
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const blob = new Blob([buffer], { type: attachment.mimeType });
const objectUrl = URL.createObjectURL(blob);
activeObjectUrls.add(objectUrl);
return objectUrl;
}
return `data:${attachment.mimeType};base64,${base64}`;
}
export async function releaseDesktopPreviewUrl(input: {
url: string;
}): Promise<void> {
if (!activeObjectUrls.has(input.url)) {
return;
}
activeObjectUrls.delete(input.url);
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(input.url);
}
}
export const __desktopPreviewUrlTestUtils = {
base64ToUint8Array,
clearActiveObjectUrls: () => {
activeObjectUrls.clear();
},
};

View File

@@ -0,0 +1,102 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions";
export interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
}
export function DesktopPermissionRow({
title,
status,
isRequesting,
showBorder,
onRequest,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
const isGranted = state === "granted";
const shouldShowDetail =
status !== null &&
status.detail.trim().length > 0 &&
state !== "granted" &&
state !== "prompt" &&
state !== "not-granted";
return (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button variant="secondary" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? <Text style={styles.permissionDetailText}>{status?.detail}</Text> : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
audioRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
audioRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
audioRowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
audioRowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
minWidth: 88,
justifyContent: "center",
},
permissionStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
permissionDetailText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
maxWidth: 220,
textAlign: "right",
},
}));

View File

@@ -0,0 +1,105 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
export function DesktopPermissionsSection() {
const { theme } = useUnistyles();
const {
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
refreshPermissions,
requestPermission,
} = useDesktopPermissions();
if (!isDesktop) {
return null;
}
const isBusy = isRefreshing || requestingPermission !== null;
return (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={styles.sectionTitle}>Desktop Permissions</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
isBusy && styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshPermissions();
}}
disabled={isBusy}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={() => {
void requestPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={() => {
void requestPermission("microphone");
}}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: 0,
marginLeft: theme.spacing[1],
},
permissionSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
permissionRefreshButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
permissionRefreshButtonDisabled: {
opacity: theme.opacity[50],
},
audioCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden",
},
}));

View File

@@ -0,0 +1,286 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
buildDaemonUpdateDiagnostics,
formatVersionWithPrefix,
getLocalDaemonVersion,
isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates";
export interface LocalDaemonSectionProps {
appVersion: string | null;
}
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showSection = shouldShowDesktopUpdateSection();
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
null
);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void getLocalDaemonVersion().then((result) => {
setLocalDaemonVersion(result.version);
setLocalDaemonVersionError(result.error);
});
return undefined;
}, [showSection])
);
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return;
}
if (isUpdatingLocalDaemon) {
return;
}
void confirmDialog({
title: "Update local daemon",
message:
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
confirmLabel: "Update daemon",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingLocalDaemon(true);
setLocalDaemonUpdateMessage(null);
setLocalDaemonUpdateDiagnostics(null);
void runLocalDaemonUpdate()
.then((result) => {
const diagnostics = buildDaemonUpdateDiagnostics(result);
if (result.exitCode !== 0) {
setLocalDaemonUpdateMessage(
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
void getLocalDaemonVersion().then((versionResult) => {
setLocalDaemonVersion(versionResult.version);
setLocalDaemonVersionError(versionResult.error);
});
})
.catch((error) => {
console.error("[Settings] Failed to update local daemon", error);
const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage(
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
})
.finally(() => {
setIsUpdatingLocalDaemon(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
});
}, [isUpdatingLocalDaemon, showSection]);
const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) {
return;
}
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
.then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error);
Alert.alert("Error", "Unable to copy diagnostics.");
});
}, [localDaemonUpdateDiagnostics]);
if (!showSection) {
return null;
}
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Local daemon</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Version</Text>
<Text style={styles.hintText}>{daemonVersionHint}</Text>
</View>
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Update daemon</Text>
<Text style={styles.hintText}>
Updates the daemon on this computer only. Requires a restart.
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handleUpdateLocalDaemon}
disabled={isUpdatingLocalDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.diagnosticsCard}>
<View style={styles.diagnosticsHeader}>
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
</Button>
</View>
<Text style={styles.diagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
card: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
valueText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
warningCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
warningText: {
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
diagnosticsCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
gap: theme.spacing[2],
},
diagnosticsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
diagnosticsTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
diagnosticsText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
}));

View File

@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
}
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value,
})
}
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else {
delete (globalThis as { navigator?: unknown }).navigator
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
vi.resetModules()
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
return import('./desktop-permissions')
}
describe('desktop-permissions', () => {
afterEach(() => {
vi.doUnmock('react-native')
vi.restoreAllMocks()
vi.resetModules()
restoreGlobals()
})
it('shows section only in Tauri web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { isPermissionGranted },
}
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
},
mediaDevices: {
getUserMedia: vi.fn(),
},
})
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted')
expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number')
})
it('queries microphone permission with correct Permissions instance binding', async () => {
const permissions = {
query(this: unknown, _descriptor: { name: string }) {
if (this !== permissions) {
throw new TypeError(
'Can only call Permissions.query on instances of Permissions'
)
}
return Promise.resolve({ state: 'granted' as const })
},
}
setNavigator({
permissions,
mediaDevices: {
getUserMedia: vi.fn(),
},
})
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.microphone.state).toBe('granted')
})
it('returns a fallback message when runtime blocks Permissions.query', async () => {
setNavigator({
permissions: {
query: vi.fn(async () => {
throw new TypeError(
'Can only call Permissions.query on instances of Permissions'
)
}),
},
mediaDevices: {
getUserMedia: vi.fn(),
},
})
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.microphone.state).toBe('unknown')
expect(snapshot.microphone.detail).toContain(
'Microphone status API is unavailable in this runtime.'
)
})
it('requests notification permission via Tauri', async () => {
const requestPermission = vi.fn(async () => 'granted')
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { requestPermission },
}
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted')
expect(requestPermission).toHaveBeenCalledTimes(1)
})
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
class MockNotification {
static permission = 'denied'
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({})
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('denied')
})
it('requests microphone permission and stops acquired tracks', async () => {
const stop = vi.fn()
const getUserMedia = vi.fn(async () => ({
getTracks: () => [{ stop }],
}))
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
},
mediaDevices: {
getUserMedia,
},
})
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'microphone' })
expect(result.state).toBe('granted')
expect(getUserMedia).toHaveBeenCalledWith({ audio: true })
expect(stop).toHaveBeenCalledTimes(1)
})
it('maps microphone request denial to denied status', async () => {
setNavigator({
mediaDevices: {
getUserMedia: vi.fn(async () => {
throw { name: 'NotAllowedError', message: 'denied' }
}),
},
})
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'microphone' })
expect(result.state).toBe('denied')
})
})

View File

@@ -0,0 +1,387 @@
import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
export type DesktopPermissionKind = 'notifications' | 'microphone'
export type DesktopPermissionState =
| 'granted'
| 'denied'
| 'prompt'
| 'not-granted'
| 'unavailable'
| 'unknown'
export interface DesktopPermissionStatus {
state: DesktopPermissionState
detail: string
}
export interface DesktopPermissionSnapshot {
checkedAt: number
notifications: DesktopPermissionStatus
microphone: DesktopPermissionStatus
}
type NotificationConstructorLike = {
permission?: string
requestPermission?: () => Promise<string>
}
type MediaStreamTrackLike = {
stop?: () => void
}
type MediaStreamLike = {
getTracks?: () => MediaStreamTrackLike[]
}
type NavigatorLike = {
mediaDevices?: {
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>
}
permissions?: {
query?: (descriptor: { name: string }) => Promise<{ state?: string }>
}
}
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
return input
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
return String(error)
}
function getErrorName(error: unknown): string | null {
if (!isObject(error)) {
return null
}
const name = error.name
return typeof name === 'string' && name.length > 0 ? name : null
}
function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
const message = getErrorMessage(error)
if (
message.includes('Can only call Permissions.query on instances of Permissions') ||
message.includes('Illegal invocation')
) {
return true
}
return false
}
function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== 'web') {
return null
}
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification
if (
NotificationConstructor == null ||
(typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object')
) {
return null
}
return NotificationConstructor as NotificationConstructorLike
}
function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== 'web') {
return null
}
const webNavigator = (globalThis as { navigator?: unknown }).navigator
if (!isObject(webNavigator)) {
return null
}
return webNavigator as NavigatorLike
}
function mapNotificationPermissionString(permission: string): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
if (permission === 'default') {
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
return status({
state: 'unknown',
detail: `Unexpected notification permission state: ${permission}`,
})
}
function mapTauriNotificationPermissionResult(
permission: TauriNotificationPermission
): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
state: 'unavailable',
detail: 'Desktop notification status is only available on web runtime.',
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
try {
const granted = await tauriNotification.isPermissionGranted()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
return status({
state: 'not-granted',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
})
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
})
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
}
return mapNotificationPermissionString(NotificationConstructor.permission)
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
state: 'unavailable',
detail: 'Desktop microphone status is only available on web runtime.',
})
}
const webNavigator = getNavigatorLike()
if (!webNavigator) {
return status({
state: 'unavailable',
detail: 'Navigator is unavailable in this environment.',
})
}
const permissionsApi = webNavigator.permissions
if (permissionsApi && typeof permissionsApi.query === 'function') {
try {
const result = await permissionsApi.query({ name: 'microphone' })
if (result?.state === 'granted') {
return status({
state: 'granted',
detail: 'Microphone access is granted.',
})
}
if (result?.state === 'denied') {
return status({
state: 'denied',
detail: 'Microphone access is denied in system settings.',
})
}
if (result?.state === 'prompt') {
return status({
state: 'prompt',
detail: 'Microphone permission has not been granted yet.',
})
}
return status({
state: 'unknown',
detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`,
})
} catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({
state: 'unknown',
detail:
'Microphone status API is unavailable in this runtime. Use Request to check access.',
})
}
return status({
state: 'unknown',
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
})
}
}
if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
return status({
state: 'unavailable',
detail: 'Microphone capture is unavailable in this environment.',
})
}
return status({
state: 'unknown',
detail: 'Permission status API is unavailable. Use Request to check access.',
})
}
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
state: 'unavailable',
detail: 'Desktop notification requests are only available on web runtime.',
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
try {
const permission = await tauriNotification.requestPermission()
return mapTauriNotificationPermissionResult(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
state: 'unavailable',
detail: 'Desktop microphone requests are only available on web runtime.',
})
}
const webNavigator = getNavigatorLike()
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
return status({
state: 'unavailable',
detail: 'Microphone capture API is unavailable in this environment.',
})
}
try {
const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true })
const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : []
tracks.forEach((track) => {
if (typeof track.stop === 'function') {
track.stop()
}
})
return await getMicrophonePermissionStatus()
} catch (error) {
const errorName = getErrorName(error)
if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') {
return status({
state: 'denied',
detail: 'Microphone permission was denied by the user or system.',
})
}
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return status({
state: 'unavailable',
detail: 'No microphone device was found.',
})
}
return status({
state: 'unknown',
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
})
}
}
export async function requestDesktopPermission(input: {
kind: DesktopPermissionKind
}): Promise<DesktopPermissionStatus> {
if (input.kind === 'notifications') {
return await requestNotificationPermissionStatus()
}
return await requestMicrophonePermissionStatus()
}
export async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
const [notifications, microphone] = await Promise.all([
getNotificationPermissionStatus(),
getMicrophonePermissionStatus(),
])
return {
checkedAt: Date.now(),
notifications,
microphone,
}
}

View File

@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
shouldShowDesktopPermissionSection,
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
} from "@/desktop/permissions/desktop-permissions";
export interface UseDesktopPermissionsReturn {
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
state: "unknown" as const,
detail: "Notification status has not been checked yet.",
};
const EMPTY_MICROPHONE_STATUS = {
state: "unknown" as const,
detail: "Microphone status has not been checked yet.",
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const isDesktop = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null
);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const refreshPermissions = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsRefreshing(true);
try {
const nextSnapshot = await getDesktopPermissionSnapshot();
if (!isMountedRef.current) {
return;
}
setSnapshot(nextSnapshot);
} catch (error) {
console.error("[Settings] Failed to load desktop permission status", error);
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
}
}
}, [isDesktop]);
const requestPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!isDesktop) {
return;
}
setRequestingPermission(kind);
try {
const status = await requestDesktopPermission({ kind });
if (!isMountedRef.current) {
return;
}
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot =
previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
};
if (kind === "notifications") {
return {
...base,
checkedAt: Date.now(),
notifications: status,
};
}
return {
...base,
checkedAt: Date.now(),
microphone: status,
};
});
} catch (error) {
console.error(`[Settings] Failed to request ${kind} permission`, error);
} finally {
if (isMountedRef.current) {
setRequestingPermission(null);
}
await refreshPermissions();
}
},
[isDesktop, refreshPermissions]
);
useEffect(() => {
if (!isDesktop) {
return;
}
void refreshPermissions();
}, [isDesktop, refreshPermissions]);
return {
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
refreshPermissions,
requestPermission,
};
}

View File

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

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
vi.resetModules()
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
return import('./desktop-updates')
}
describe('desktop-updates helpers', () => {
afterEach(() => {
vi.doUnmock('react-native')
vi.restoreAllMocks()
vi.resetModules()
})
it('normalizes versions for app-daemon comparisons', async () => {
const { normalizeVersionForComparison } = await loadModuleForPlatform('web')
expect(normalizeVersionForComparison(' v0.1.15 ')).toBe('0.1.15')
expect(normalizeVersionForComparison('0.1.15')).toBe('0.1.15')
expect(normalizeVersionForComparison(null)).toBeNull()
})
it('detects version mismatch after normalization', async () => {
const { isVersionMismatch } = await loadModuleForPlatform('web')
expect(isVersionMismatch('v0.1.15', '0.1.15')).toBe(false)
expect(isVersionMismatch('0.1.15', '0.1.16')).toBe(true)
expect(isVersionMismatch('0.1.15', null)).toBe(false)
})
it('formats display versions with v prefix and unavailable fallback', async () => {
const { formatVersionWithPrefix } = await loadModuleForPlatform('web')
expect(formatVersionWithPrefix('0.2.0')).toBe('v0.2.0')
expect(formatVersionWithPrefix('v0.2.0')).toBe('v0.2.0')
expect(formatVersionWithPrefix(null)).toBe('\u2014')
})
it('parses valid local daemon version result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(parseLocalDaemonVersionResult({ version: '0.1.15', error: null })).toEqual({
version: '0.1.15',
error: null,
})
})
it('parses local daemon version error result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(
parseLocalDaemonVersionResult({ version: null, error: 'paseo command not found in PATH' })
).toEqual({
version: null,
error: 'paseo command not found in PATH',
})
})
it('parses unexpected local daemon version result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(parseLocalDaemonVersionResult(null)).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
expect(parseLocalDaemonVersionResult('not an object')).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
})
it('trims whitespace in parsed version', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(parseLocalDaemonVersionResult({ version: ' 0.1.15 ', error: null })).toEqual({
version: '0.1.15',
error: null,
})
})
it('builds copyable daemon update diagnostics', async () => {
const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web')
const diagnostics = buildDaemonUpdateDiagnostics({
exitCode: 1,
stdout: 'stdout text',
stderr: 'stderr text',
})
expect(diagnostics).toContain('Exit code: 1')
expect(diagnostics).toContain('STDOUT:\nstdout text')
expect(diagnostics).toContain('STDERR:\nstderr text')
})
})

View File

@@ -0,0 +1,157 @@
import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
body: string | null
date: string | null
}
export interface DesktopAppUpdateInstallResult {
installed: boolean
version: string | null
message: string
}
export interface LocalDaemonUpdateResult {
exitCode: number
stdout: string
stderr: string
}
export interface LocalDaemonVersionResult {
version: string | null
error: string | null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function toStringOrNull(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function toStringOrEmpty(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function toNumberOr(defaultValue: number, value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
if (!isRecord(raw)) {
return { version: null, error: 'Unexpected response from version check.' }
}
return {
version: toStringOrNull(raw.version),
error: toStringOrNull(raw.error),
}
}
export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult> {
const result = await invokeDesktopCommand<unknown>('get_local_daemon_version')
return parseLocalDaemonVersionResult(result)
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>('check_app_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while checking desktop updates.')
}
return {
hasUpdate: result.hasUpdate === true,
currentVersion: toStringOrNull(result.currentVersion),
latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body),
date: toStringOrNull(result.date),
}
}
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>('install_app_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while installing desktop update.')
}
return {
installed: result.installed === true,
version: toStringOrNull(result.version),
message: toStringOrNull(result.message) ?? 'Update completed.',
}
}
export async function runLocalDaemonUpdate(): Promise<LocalDaemonUpdateResult> {
const result = await invokeDesktopCommand<unknown>('run_local_daemon_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while updating local daemon.')
}
return {
exitCode: toNumberOr(1, result.exitCode),
stdout: toStringOrEmpty(result.stdout),
stderr: toStringOrEmpty(result.stderr),
}
}
export function normalizeVersionForComparison(version: string | null | undefined): string | null {
const value = version?.trim()
if (!value) {
return null
}
return value.replace(/^v/i, '')
}
export function isVersionMismatch(
appVersion: string | null | undefined,
daemonVersion: string | null | undefined
): boolean {
const app = normalizeVersionForComparison(appVersion)
const daemon = normalizeVersionForComparison(daemonVersion)
if (!app || !daemon) {
return false
}
return app !== daemon
}
export function formatVersionWithPrefix(version: string | null | undefined): string {
const value = version?.trim()
if (!value) {
return '\u2014'
}
return value.startsWith('v') ? value : `v${value}`
}
export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
return [
`Exit code: ${result.exitCode}`,
'',
'STDOUT:',
stdout,
'',
'STDERR:',
stderr,
].join('\n')
}

View File

@@ -0,0 +1,183 @@
import { useCallback, useRef, useState } from 'react'
import {
checkDesktopAppUpdate,
formatVersionWithPrefix,
installDesktopAppUpdate,
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from '@/desktop/updates/desktop-updates'
export type DesktopAppUpdateStatus =
| 'idle'
| 'checking'
| 'up-to-date'
| 'available'
| 'installing'
| 'installed'
| 'error'
export interface UseDesktopAppUpdaterReturn {
isDesktop: boolean
status: DesktopAppUpdateStatus
statusText: string
availableUpdate: DesktopAppUpdateCheckResult | null
errorMessage: string | null
lastCheckedAt: number | null
isChecking: boolean
isInstalling: boolean
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
return String(error)
}
function formatStatusText(input: {
status: DesktopAppUpdateStatus
availableUpdate: DesktopAppUpdateCheckResult | null
installMessage: string | null
}): string {
const { status, availableUpdate, installMessage } = input
if (status === 'checking') {
return 'Checking for app updates...'
}
if (status === 'installing') {
return 'Installing app update...'
}
if (status === 'up-to-date') {
return 'App is up to date.'
}
if (status === 'available') {
if (availableUpdate?.latestVersion) {
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
}
return 'An app update is available.'
}
if (status === 'installed') {
return installMessage ?? 'App update installed. Restart required.'
}
if (status === 'error') {
return 'Failed to update app.'
}
return 'Update status has not been checked yet.'
}
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktop = shouldShowDesktopUpdateSection()
const requestVersionRef = useRef(0)
const [status, setStatus] = useState<DesktopAppUpdateStatus>('idle')
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [installMessage, setInstallMessage] = useState<string | null>(null)
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null)
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktop) {
return null
}
const requestVersion = requestVersionRef.current + 1
requestVersionRef.current = requestVersion
if (!options.silent) {
setStatus('checking')
}
setErrorMessage(null)
try {
const result = await checkDesktopAppUpdate()
if (requestVersion !== requestVersionRef.current) {
return result
}
setInstallMessage(null)
setLastCheckedAt(Date.now())
if (result.hasUpdate) {
setAvailableUpdate(result)
setStatus('available')
} else {
setAvailableUpdate(null)
setStatus('up-to-date')
}
return result
} catch (error) {
if (requestVersion !== requestVersionRef.current) {
return null
}
const message = getErrorMessage(error)
if (options.silent) {
console.warn('[DesktopUpdater] Silent update check failed', message)
} else {
setStatus('error')
setErrorMessage(message)
}
return null
}
},
[isDesktop]
)
const installUpdate = useCallback(async () => {
if (!isDesktop) {
return null
}
setStatus('installing')
setErrorMessage(null)
try {
const result = await installDesktopAppUpdate()
setLastCheckedAt(Date.now())
if (result.installed) {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('installed')
} else {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('up-to-date')
}
return result
} catch (error) {
const message = getErrorMessage(error)
setStatus('error')
setErrorMessage(message)
return null
}
}, [isDesktop])
return {
isDesktop,
status,
statusText: formatStatusText({
status,
availableUpdate,
installMessage,
}),
availableUpdate,
errorMessage,
lastCheckedAt,
isChecking: status === 'checking',
isInstalling: status === 'installing',
checkForUpdates,
installUpdate,
}
}

View File

@@ -0,0 +1,289 @@
import { useCallback, useMemo } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type { AutocompleteOption } from '@/components/ui/autocomplete'
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
import { useAutocomplete } from './use-autocomplete'
import { useSessionStore } from '@/stores/session-store'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import {
applyFileMentionReplacement,
findActiveFileMention,
type FileMentionRange,
} from '@/utils/file-mention-autocomplete'
interface UseAgentAutocompleteInput {
userInput: string
cursorIndex: number
setUserInput: (nextValue: string) => void
serverId: string
agentId: string
draftConfig?: DraftCommandConfig
onAutocompleteApplied?: () => void
}
type AgentAutocompleteOption =
| (AutocompleteOption & { type: 'command' })
| (AutocompleteOption & {
type: 'workspace_entry'
entryPath: string
mention: FileMentionRange
})
interface AgentAutocompleteResult {
isVisible: boolean
options: AutocompleteOption[]
selectedIndex: number
isLoading: boolean
errorMessage?: string
loadingText: string
emptyText: string
onSelectOption: (option: AutocompleteOption) => void
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
}
interface DirectorySuggestionEntry {
path: string
kind: 'file' | 'directory'
}
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig
): DraftCommandConfig | undefined {
if (!draftConfig) {
return undefined
}
const cwd = draftConfig.cwd.trim()
if (!cwd) {
return undefined
}
const modeId = draftConfig.modeId?.trim() ?? ''
const model = draftConfig.model?.trim() ?? ''
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
}
}
function mapDirectorySuggestionsToEntries(payload: {
entries?: Array<{ path: string; kind: string }>
directories?: string[]
}): DirectorySuggestionEntry[] {
if (Array.isArray(payload.entries) && payload.entries.length > 0) {
return payload.entries.flatMap((entry) => {
if (
!entry ||
typeof entry.path !== 'string' ||
(entry.kind !== 'file' && entry.kind !== 'directory')
) {
return []
}
return [{ path: entry.path, kind: entry.kind }]
})
}
return (payload.directories ?? []).map((path) => ({
path,
kind: 'directory' as const,
}))
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const {
userInput,
cursorIndex,
setUserInput,
serverId,
agentId,
draftConfig,
onAutocompleteApplied,
} = input
const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
const activeFileMention = useMemo(
() =>
findActiveFileMention({
text: userInput,
cursorIndex,
}),
[cursorIndex, userInput]
)
const showFileAutocomplete = activeFileMention !== null
const fileFilterQuery = activeFileMention?.query ?? ''
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig]
)
const isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
)
const autocompleteCwd = useMemo(() => {
if (isRealAgent) {
return agentCwd.trim()
}
return queryDraftConfig?.cwd ?? ''
}, [agentCwd, isRealAgent, queryDraftConfig])
const { client, isConnected } = useHostRuntimeSession(serverId)
const mode: 'command' | 'file' | null = showFileAutocomplete
? 'file'
: showCommandAutocomplete
? 'command'
: null
const isVisible =
mode === 'command'
? canLoadCommands
: mode === 'file'
? Boolean(serverId) && autocompleteCwd.length > 0
: false
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
serverId,
agentId,
enabled: mode === 'command' && canLoadCommands,
draftConfig: queryDraftConfig,
})
const fileSuggestionsQuery = useQuery({
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error('Daemon client unavailable')
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
query: fileFilterQuery,
limit: 50,
includeFiles: true,
includeDirectories: true,
})
if (response.error) {
throw new Error(response.error)
}
return mapDirectorySuggestionsToEntries(response)
},
enabled:
mode === 'file' &&
Boolean(serverId) &&
autocompleteCwd.length > 0 &&
Boolean(client) &&
isConnected,
retry: false,
staleTime: 15_000,
placeholderData: keepPreviousData,
})
const options = useMemo<AgentAutocompleteOption[]>(() => {
if (!isVisible) {
return []
}
if (mode === 'command') {
const filterLower = commandFilterQuery.toLowerCase()
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
const orderedMatches = orderAutocompleteOptions(matches)
return orderedMatches.map((cmd) => ({
type: 'command' as const,
id: cmd.name,
label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined,
description: cmd.description,
kind: 'command',
}))
}
if (mode === 'file' && activeFileMention) {
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? [])
return orderedEntries.map((entry) => ({
type: 'workspace_entry' as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}))
}
return []
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
const selected = option as AgentAutocompleteOption
if (selected.type === 'command') {
setUserInput(`/${selected.id} `)
onAutocompleteApplied?.()
return
}
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
relativePath: selected.entryPath,
})
setUserInput(nextInput)
onAutocompleteApplied?.()
},
[onAutocompleteApplied, setUserInput, userInput]
)
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onEscape: mode === 'command' ? () => setUserInput('') : undefined,
})
const isLoading =
mode === 'command'
? isCommandsLoading
: mode === 'file'
? fileSuggestionsQuery.isPending || (fileSuggestionsQuery.isLoading && options.length === 0)
: false
const errorMessage =
mode === 'command'
? isError
? (error?.message ?? 'Failed to load')
: undefined
: mode === 'file'
? fileSuggestionsQuery.error instanceof Error
? fileSuggestionsQuery.error.message
: undefined
: undefined
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
return {
isVisible,
options,
selectedIndex,
isLoading,
errorMessage,
loadingText,
emptyText,
onSelectOption,
onKeyPress,
}
}

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
@@ -9,35 +10,53 @@ interface AgentSlashCommand {
argumentHint: string;
}
function commandsQueryKey(serverId: string, agentId: string) {
return ["agentCommands", serverId, agentId] as const;
export interface DraftCommandConfig {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
}
function commandsQueryKey(
serverId: string,
agentId: string,
draftConfig?: DraftCommandConfig
) {
return [
"agentCommands",
serverId,
agentId,
draftConfig?.provider ?? null,
draftConfig?.cwd ?? null,
draftConfig?.modeId ?? null,
draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null,
] as const;
}
interface UseAgentCommandsQueryOptions {
serverId: string;
agentId: string;
enabled?: boolean;
draftConfig?: DraftCommandConfig;
}
export function useAgentCommandsQuery({
serverId,
agentId,
enabled = true,
draftConfig,
}: UseAgentCommandsQueryOptions) {
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const { client, isConnected } = useHostRuntimeSession(serverId);
const query = useQuery({
queryKey: commandsQueryKey(serverId, agentId),
queryKey: commandsQueryKey(serverId, agentId, draftConfig),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const response = await client.listCommands(agentId);
const response = await client.listCommands(agentId, { draftConfig });
return response.commands as AgentSlashCommand[];
},
enabled: enabled && !!client && isConnected && !!agentId,

View File

@@ -9,8 +9,8 @@ import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import { useSessionStore } from "@/stores/session-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
// Explicit overrides from URL params or "New Agent" button
@@ -364,11 +364,7 @@ export function useAgentFormState(
}, [isVisible]);
// Session state for provider model listing
const sessionState = useSessionStore((state) =>
formState.serverId ? state.sessions[formState.serverId] : undefined
);
const client = sessionState?.client ?? null;
const isConnected = sessionState?.connection?.isConnected ?? false;
const { client, isConnected } = useHostRuntimeSession(formState.serverId ?? "");
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],

View File

@@ -1,6 +1,9 @@
import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import type { FetchAgentTimelineOptions } from "@server/client/daemon-client";
import type {
DaemonClient,
FetchAgentTimelineOptions,
} from "@server/client/daemon-client";
import {
attachInitTimeout,
createInitDeferred,
@@ -37,8 +40,13 @@ function buildInitialTimelineRequest(
};
}
export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
export function useAgentInitialization({
serverId,
client,
}: {
serverId: string;
client: DaemonClient | null;
}) {
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useCallback(
(agentId: string, initializing: boolean) => {

View File

@@ -0,0 +1,444 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/contexts/session-context";
import {
deriveAgentScreenViewState,
type AgentScreenMachineInput,
type AgentScreenMachineMemory,
type AgentScreenViewState,
} from "./use-agent-screen-state-machine";
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
return {
serverId: "server-1",
id,
provider: "claude",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "claude",
sessionId: "session-1",
model: null,
modeId: null,
},
title: "Agent",
cwd: "/repo",
model: null,
labels: {},
};
}
function createBaseInput(): AgentScreenMachineInput {
return {
agent: null,
placeholderAgent: null,
missingAgentState: { kind: "idle" },
isConnected: true,
isArchivingCurrentAgent: false,
isHistorySyncing: false,
needsAuthoritativeSync: false,
shouldUseOptimisticStream: false,
hasHydratedHistoryBefore: false,
};
}
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
): AgentScreenMachineMemory {
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
}
function expectReadyState(state: AgentScreenViewState): ReadyState {
expect(state.tag).toBe("ready");
if (state.tag !== "ready") {
throw new Error("expected ready state");
}
return state;
}
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
expect(state.sync.status).toBe("catching_up");
if (state.sync.status !== "catching_up") {
throw new Error("expected catching_up sync state");
}
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory = createBaseMemory();
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
expect(result.state.tag).toBe("boot");
if (result.state.tag !== "boot") {
throw new Error("expected boot state");
}
expect(result.state.reason).toBe("loading");
expect(result.state.source).toBe("none");
});
it("stays ready after first paint even if agent is temporarily missing", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(ready.source).toBe("stale");
expect(ready.sync.status).toBe("idle");
expect(ready.agent.id).toBe("agent-1");
});
it("shows reconnecting sync status without blocking after first paint", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
isConnected: false,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(ready.sync.status).toBe("reconnecting");
});
it("shows overlay catching-up state for first open while loading history", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const errorInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const errorResult = deriveAgentScreenViewState({
input: errorInput,
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "idle" },
};
const retryResult = deriveAgentScreenViewState({
input: retryInput,
memory: errorResult.memory,
});
const retryReady = expectReadyState(retryResult.state);
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
it("keeps ready with sync_error when refresh fails after first paint", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
expect(result.state.tag).toBe("error");
if (result.state.tag !== "error") {
throw new Error("expected error state");
}
expect(result.state.message).toContain("network timeout");
});
it("returns not_found when resolver confirms missing agent", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "not_found", message: "agent missing" },
};
const result = deriveAgentScreenViewState({ input, memory });
expect(result.state.tag).toBe("not_found");
if (result.state.tag !== "not_found") {
throw new Error("expected not_found state");
}
expect(result.state.message).toContain("missing");
});
it("promotes optimistic source while placeholder is used", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
shouldUseOptimisticStream: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(ready.source).toBe("optimistic");
expect(ready.sync.status).toBe("idle");
});
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory = createBaseMemory();
const optimisticInput: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
shouldUseOptimisticStream: true,
};
const optimistic = deriveAgentScreenViewState({
input: optimisticInput,
memory: initialMemory,
});
const optimisticReady = expectReadyState(optimistic.state);
expect(optimisticReady.source).toBe("optimistic");
const handoffInput: AgentScreenMachineInput = {
...createBaseInput(),
};
const handoff = deriveAgentScreenViewState({
input: handoffInput,
memory: optimistic.memory,
});
const handoffReady = expectReadyState(handoff.state);
expect(handoffReady.source).toBe("stale");
expect(handoffReady.agent.id).toBe("draft-agent");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
hadInitialSyncFailure: true,
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});

View File

@@ -0,0 +1,229 @@
import { useRef } from "react";
import type { Agent } from "@/contexts/session-context";
export type AgentScreenMissingState =
| { kind: "idle" }
| { kind: "resolving" }
| { kind: "not_found"; message: string }
| { kind: "error"; message: string };
export interface AgentScreenMachineInput {
agent: Agent | null;
placeholderAgent: Agent | null;
missingAgentState: AgentScreenMissingState;
isConnected: boolean;
isArchivingCurrentAgent: boolean;
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
shouldUseOptimisticStream: boolean;
hasHydratedHistoryBefore: boolean;
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
export type AgentScreenReadySyncState =
| { status: "idle" }
| { status: "reconnecting" }
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
export type AgentScreenViewState =
| {
tag: "boot";
reason: "loading" | "resolving";
source: "none";
}
| {
tag: "not_found";
message: string;
}
| {
tag: "error";
message: string;
}
| {
tag: "ready";
agent: Agent;
source: "authoritative" | "optimistic" | "stale";
sync: AgentScreenReadySyncState;
isArchiving: boolean;
};
export function deriveAgentScreenViewState({
input,
memory,
}: {
input: AgentScreenMachineInput;
memory: AgentScreenMachineMemory;
}): { state: AgentScreenViewState; memory: AgentScreenMachineMemory } {
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
if (input.hasHydratedHistoryBefore) {
nextMemory.hadInitialSyncFailure = false;
}
if (
input.missingAgentState.kind === "error" &&
!input.hasHydratedHistoryBefore
) {
nextMemory.hadInitialSyncFailure = true;
}
const candidateAgent = input.agent ?? input.placeholderAgent;
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
nextMemory.lastReadyAgent = candidateAgent;
}
if (input.missingAgentState.kind === "not_found") {
return {
state: {
tag: "not_found",
message: input.missingAgentState.message,
},
memory: nextMemory,
};
}
if (input.missingAgentState.kind === "error" && !nextMemory.hasRenderedReady) {
return {
state: {
tag: "error",
message: input.missingAgentState.message,
},
memory: nextMemory,
};
}
const displayAgent =
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
if (!displayAgent) {
return {
state: {
tag: "boot",
reason: input.missingAgentState.kind === "resolving" ? "resolving" : "loading",
source: "none",
},
memory: nextMemory,
};
}
const source: "authoritative" | "optimistic" | "stale" = input.agent
? "authoritative"
: input.shouldUseOptimisticStream
? "optimistic"
: "stale";
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
let ui: "overlay" | "toast" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast =
memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
return {
state: {
tag: "ready",
agent: displayAgent,
source,
sync,
isArchiving: input.isArchivingCurrentAgent,
},
memory: nextMemory,
};
}
export function useAgentScreenStateMachine({
routeKey,
input,
}: {
routeKey: string;
input: AgentScreenMachineInput;
}): AgentScreenViewState {
const routeKeyRef = useRef(routeKey);
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
if (routeKeyRef.current !== routeKey) {
routeKeyRef.current = routeKey;
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}
const result = deriveAgentScreenViewState({
input,
memory: memoryRef.current,
});
memoryRef.current = result.memory;
return result.state;
}

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