Compare commits

..

106 Commits

Author SHA1 Message Date
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
Mohamed Boudra
36ac228bef chore(release): cut 0.1.6 2026-02-16 08:29:45 +07:00
Mohamed Boudra
12eade8f98 fix(release): avoid double-bumping workspace versions 2026-02-16 08:29:35 +07:00
Mohamed Boudra
b105409844 chore(release): cut 0.1.5 2026-02-16 08:27:54 +07:00
Mohamed Boudra
22c46a9c73 chore(release): add tag-driven mobile workflow and release docs 2026-02-16 08:27:39 +07:00
Mohamed Boudra
037a6a15a4 feat: stream worktree setup progress and terminal subscriptions 2026-02-16 08:09:27 +07:00
Mohamed Boudra
b980abb7af Remove temporary terminal debug instrumentation 2026-02-15 19:10:40 +07:00
Mohamed Boudra
0103b4da96 Harden terminal streaming and worktree setup flow 2026-02-15 19:05:27 +07:00
Mohamed Boudra
75dc802a3a chore(app): add terminal input/output debug logging 2026-02-15 15:43:07 +07:00
Mohamed Boudra
adb511d2be refactor(app): extract terminal runtime out of react lifecycle 2026-02-15 15:40:18 +07:00
Mohamed Boudra
9244f1fbd6 feat: terminal reattach, explorer tab memory, worktree terminals 2026-02-15 14:05:29 +07:00
Mohamed Boudra
1f72ce63cd config: add default worktree terminal 2026-02-15 09:37:36 +07:00
Mohamed Boudra
41d1fa6d97 feat: update sidebar agent workflow and host filter UI 2026-02-15 08:59:17 +07:00
Mohamed Boudra
9da5b26db0 feat(app): add global shortcut help dialog 2026-02-14 20:18:28 +07:00
Mohamed Boudra
756f9a7972 refactor(app): centralize keyboard actions and shortcuts 2026-02-14 19:53:19 +07:00
Mohamed Boudra
86b476e309 fix(app): remove terminal debug instrumentation 2026-02-14 18:09:07 +07:00
279 changed files with 32314 additions and 11398 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

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

99
CHANGELOG.md Normal file
View File

@@ -0,0 +1,99 @@
# Changelog
## [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

@@ -109,7 +109,7 @@ Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package
- `production` -> app name `Paseo`, package `sh.paseo`
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
EAS profiles live in `packages/app/eas.json` as `development` and `production`.
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
`development` uses Android `debug`.
@@ -136,6 +136,29 @@ npm run android:production
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
That workflow does:
- Build iOS with the `production` profile
- Build Android with the `production` profile
- Submit each build with the `production` submit profile
Useful commands:
```bash
# List recent mobile workflow runs
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect one run (jobs, status, outputs)
cd packages/app && npx eas workflow:view <run-id>
# Stream logs for all steps in one failed job
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
```
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
@@ -160,14 +183,22 @@ 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 release)
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
```
Notes:
- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
Release completion checklist:
- `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,77 +17,44 @@
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
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:website
# repo-wide checks
npm run typecheck
```
Optional configuration:
- `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.
## Desktop 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
```
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).
## License
MIT

65
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.4",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.4",
"version": "0.1.14",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13681,6 +13681,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lezer-elixir": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz",
"integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==",
"dependencies": {
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"license": "Apache-2.0",
@@ -14705,7 +14714,9 @@
},
"node_modules/nan": {
"version": "2.23.0",
"license": "MIT"
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
@@ -14770,6 +14781,12 @@
"version": "2.0.1",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"funding": [
@@ -14816,6 +14833,16 @@
"version": "0.4.0",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.2.0-beta.11",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
"version": "2.0.26",
"license": "MIT"
@@ -20335,7 +20362,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.4",
"version": "0.1.14",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -20343,7 +20370,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.4",
"@getpaseo/server": "0.1.14",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -20390,6 +20417,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",
@@ -20446,11 +20474,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.4",
"version": "0.1.14",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"@getpaseo/relay": "0.1.14",
"@getpaseo/server": "0.1.14",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -20500,14 +20528,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.4",
"version": "0.1.14",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.4",
"version": "0.1.14",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -20523,12 +20551,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.4",
"version": "0.1.14",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/relay": "0.1.14",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20546,8 +20574,9 @@
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"lezer-elixir": "^1.1.2",
"mnemonic-id": "^3.2.7",
"node-pty": "^1.0.0",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"pino": "^10.2.0",
@@ -20791,14 +20820,6 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/node-pty": {
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
},
"packages/server/node_modules/qs": {
"version": "6.14.0",
"license": "BSD-3-Clause",
@@ -20879,7 +20900,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.4",
"version": "0.1.14",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.4",
"version": "0.1.14",
"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,14 +30,14 @@
"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",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",

View File

@@ -0,0 +1,38 @@
name: Release Mobile
on:
push:
tags:
- "v*"
workflow_dispatch: {}
jobs:
build_ios:
name: Build iOS
type: build
params:
platform: ios
profile: production
build_android:
name: Build Android
type: build
params:
platform: android
profile: production
submit_ios:
name: Submit iOS
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
submit_android:
name: Submit Android
needs: [build_android]
type: submit
params:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production

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",
@@ -112,6 +150,6 @@ export default {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
},
},
owner: "moboudra",
owner: "getpaseo",
},
};

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

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

View File

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

@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();

View File

@@ -95,6 +95,33 @@ async function openTerminalsPanel(page: Page): Promise<void> {
});
}
async function openFilesPanel(page: Page): Promise<void> {
const filesTab = page.getByTestId("explorer-tab-files").first();
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
timeout: 30000,
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
state?: { desktop?: { agentListOpen?: boolean } };
};
const value = parsed?.state?.desktop?.agentListOpen;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
@@ -105,6 +132,16 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
await tabs.last().click();
}
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid");
if (!value) {
throw new Error("Expected terminal tab test id");
}
return value;
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
@@ -213,6 +250,231 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
await runTerminalCommand(
page,
"for i in $(seq 1 12000); do echo reattach-$i; done",
"reattach-12000"
);
for (let attempt = 0; attempt < 4; attempt += 1) {
await openFilesPanel(page);
await openTerminalsPanel(page);
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
timeout: 30000,
});
}
const marker = `reattach-health-${Date.now()}`;
await runTerminalCommand(page, `echo ${marker}`, marker);
} finally {
await repo.cleanup();
}
});
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
for (let iteration = 0; iteration < 40; iteration += 1) {
await page.keyboard.press("Enter");
}
const markerAfterEnters = `echo-visible-${Date.now()}`;
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterEnters, {
timeout: 30000,
});
const longSuffix = "x".repeat(120);
await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 });
for (let iteration = 0; iteration < longSuffix.length; iteration += 1) {
await page.keyboard.press("Backspace");
}
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(
"printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'",
{ delay: 0 }
);
await page.keyboard.press("Enter");
const marker = `post-alt-screen-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal tab is removed when shell exits", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal exit flow");
await openTerminalsPanel(page);
const exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 });
await page.keyboard.press("Enter");
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const nextTabTestId = await getFirstTerminalTabTestId(page);
expect(nextTabTestId).not.toBe(exitedTabTestId);
} finally {
await repo.cleanup();
}
});
test("closing terminal with running command asks for confirmation", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-close-confirm-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal close confirmation");
await openTerminalsPanel(page);
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = 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-");
@@ -256,6 +518,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
}
});
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal key combo capture");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("cat -v", { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText("cat -v", { timeout: 30000 });
await page.keyboard.press("Escape");
await expect(surface).toContainText("^[", { timeout: 30000 });
await page.keyboard.press("Control+C");
await expect(surface).toContainText("^C", { timeout: 30000 });
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
const marker = `terminal-key-capture-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(false);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;

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": {
@@ -30,6 +34,13 @@
}
},
"submit": {
"production": {}
"production": {
"ios": {
"ascAppId": "6758887924"
},
"android": {
"releaseStatus": "draft"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.4",
"version": "0.1.14",
"private": true,
"scripts": {
"start": "expo start",
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.4",
"@getpaseo/server": "0.1.14",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -77,6 +77,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

@@ -36,19 +36,33 @@ import {
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
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";
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();
@@ -56,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>;
@@ -144,8 +167,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useGlobalKeyboardNav({
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile,
toggleAgentList,
@@ -169,7 +194,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const openGesture = useMemo(
() =>
Gesture.Pan()
.enabled(chromeEnabled && isMobile && !isOpen)
.enabled(openGestureEnabled)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
@@ -198,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
@@ -214,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)();
@@ -225,15 +261,14 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
isGesturing.value = false;
}),
[
chromeEnabled,
isMobile,
isOpen,
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
openAgentList,
mobileView,
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
@@ -255,6 +290,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<KeyboardShortcutsDialog />
</View>
);

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>
@@ -883,11 +883,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 +1120,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>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -118,7 +118,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 +157,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 +199,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 +253,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 +275,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 +307,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 +346,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,15 +99,19 @@ 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 streamItemCountRef = useRef(0);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -154,7 +162,12 @@ export function AgentStreamView({
requestFilePreview(agentId, normalized.file);
}
setExplorerTab("files");
setExplorerTabForCheckout({
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files",
});
openFileExplorer();
},
[
@@ -163,7 +176,7 @@ export function AgentStreamView({
requestDirectoryListing,
requestFilePreview,
selectExplorerEntry,
setExplorerTab,
setExplorerTabForCheckout,
openFileExplorer,
]
);
@@ -179,8 +192,12 @@ export function AgentStreamView({
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
}
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onScroll(event);
}
},
[insets.bottom]
[insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
@@ -309,6 +326,7 @@ export function AgentStreamView({
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -641,14 +659,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}
@@ -661,9 +690,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

@@ -48,9 +48,13 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}

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

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
runOnJS,
@@ -19,44 +18,39 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
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 isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
console.log(`[ExplorerSidebar] ${event}`, details);
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet";
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
return 0;
}
if (payload) {
console.log("[TerminalDebug][ExplorerSidebar] " + message, payload);
return;
}
console.log("[TerminalDebug][ExplorerSidebar] " + message);
return Math.max(0, rawHeight - inset);
}
interface ExplorerSidebarProps {
serverId: string;
agentId: string;
cwd: string;
isGit: boolean;
}
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile =
@@ -66,13 +60,13 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const terminalDebugEnabled = isTerminalDebugEnabled();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const closeGestureLastLogX = useSharedValue(0);
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
useEffect(() => {
bottomInset.value = insets.bottom;
@@ -108,72 +102,26 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
const logCloseGesture = useCallback(
(phase: string, payload?: Record<string, unknown>) => {
logTerminalDebug("close gesture " + phase, {
isMobile,
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
explorerTab,
...(payload ?? {}),
mobileView,
desktopFileExplorerOpen,
});
closeToAgent();
},
[explorerTab, isMobile, isOpen]
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
);
const logKeyboardInset = useCallback(
(rawHeight: number, shift: number, inset: number) => {
logTerminalDebug("keyboard inset", {
rawHeight: Math.round(rawHeight),
shift: Math.round(shift),
inset: Math.round(inset),
isMobile,
isOpen,
explorerTab,
});
},
[explorerTab, isMobile, isOpen]
);
useEffect(() => {
if (!terminalDebugEnabled) {
return;
}
logTerminalDebug("close gesture config", { isMobile, isOpen, explorerTab });
}, [explorerTab, isMobile, isOpen, terminalDebugEnabled]);
const enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTab(tab);
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
},
[setExplorerTab]
);
useAnimatedReaction(
() => {
const rawHeight = Math.abs(keyboardHeight.value);
return {
rawHeight,
shift: Math.max(0, rawHeight - bottomInset.value),
inset: bottomInset.value,
};
},
(next, previous) => {
if (!terminalDebugEnabled) {
return;
}
if (
previous &&
Math.abs(previous.shift - next.shift) < 4 &&
Math.abs(previous.rawHeight - next.rawHeight) < 4
) {
return;
}
runOnJS(logKeyboardInset)(next.rawHeight, next.shift, next.inset);
}
[cwd, isGit, serverId, setExplorerTabForCheckout]
);
// Swipe gesture to close (swipe right on mobile)
@@ -181,18 +129,47 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isMobile && isOpen)
// Only activate on rightward swipe (positive X), fail on leftward or vertical
// This allows ScrollViews using waitFor to scroll left normally
.activeOffsetX(15)
.failOffsetX(-10)
.failOffsetY([-10, 10])
.enabled(enableSidebarCloseGesture)
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional rightward movement.
if (deltaX >= 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
if (terminalDebugEnabled) {
closeGestureLastLogX.value = 0;
runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) });
}
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
@@ -200,58 +177,39 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
if (
terminalDebugEnabled &&
Math.abs(newTranslateX - closeGestureLastLogX.value) >= 80
) {
closeGestureLastLogX.value = newTranslateX;
runOnJS(logCloseGesture)("update", {
translationX: Math.round(event.translationX),
appliedTranslateX: Math.round(newTranslateX),
velocityX: Math.round(event.velocityX),
});
}
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("end", {
translationX: Math.round(event.translationX),
velocityX: Math.round(event.velocityX),
shouldClose,
});
}
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
runOnJS(handleClose)("swipe-close-gesture");
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("finalize");
}
}),
[
isMobile,
isOpen,
explorerTab,
enableSidebarCloseGesture,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
logCloseGesture,
isGesturing,
closeGestureRef,
closeGestureLastLogX,
terminalDebugEnabled,
closeTouchStartX,
closeTouchStartY,
]
);
@@ -295,7 +253,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
@@ -305,15 +263,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
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">
@@ -329,10 +291,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
onClose={() => handleClose("header-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}
isGit={isGit}
isMobile={isMobile}
/>
</Animated.View>
@@ -361,10 +324,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}
isGit={isGit}
isMobile={false}
/>
</Animated.View>
@@ -378,6 +342,7 @@ interface SidebarContentProps {
serverId: string;
agentId: string;
cwd: string;
isGit: boolean;
isMobile: boolean;
}
@@ -388,19 +353,12 @@ function SidebarContent({
serverId,
agentId,
cwd,
isGit,
isMobile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
const hasResolvedCheckoutStatus = status !== null;
// Switch to Files tab if Changes tab is hidden and user was on it
useEffect(() => {
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
onTabPress("files");
}
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -410,13 +368,13 @@ function SidebarContent({
{isGit && (
<Pressable
testID="explorer-tab-changes"
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
Changes
@@ -425,13 +383,13 @@ function SidebarContent({
)}
<Pressable
testID="explorer-tab-files"
style={[styles.tab, activeTab === "files" && styles.tabActive]}
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
activeTab === "files" && styles.tabTextActive,
resolvedTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -439,13 +397,13 @@ function SidebarContent({
</Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
activeTab === "terminals" && styles.tabTextActive,
resolvedTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
@@ -463,13 +421,13 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{activeTab === "changes" && (
{resolvedTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{activeTab === "files" && (
{resolvedTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{activeTab === "terminals" && (
{resolvedTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
)}
</View>

View File

@@ -5,6 +5,9 @@ import {
FlatList,
Image as RNImage,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView as RNScrollView,
Text,
@@ -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,6 +93,7 @@ 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;
@@ -136,6 +144,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);
@@ -592,6 +602,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
);
}
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]
);
return (
<View
style={styles.container}
@@ -657,7 +685,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 +737,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 +799,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 +858,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 +874,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 +937,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 +986,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 +1455,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

@@ -11,7 +11,11 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
const { theme } = useUnistyles();
return (
@@ -22,7 +26,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
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

@@ -1,48 +1,95 @@
import type { ReactNode } from "react";
import { Text } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
function MobileMenuIcon({ color }: { color: string }) {
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/>
</View>
);
}
export function SidebarMenuToggle({
style,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={theme.iconSize.md} color={menuIconColor} />
)}
</HeaderToggleButton>
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
return (
<ScreenHeader
left={
<>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -69,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));

View File

@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
const 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

View File

@@ -0,0 +1,96 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
[isMac, isTauri]
);
return (
<AdaptiveModalSheet
title="Keyboard shortcuts"
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"
snapPoints={["70%", "92%"]}
>
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.rows}>
{section.rows.map((row) => (
<View key={row.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{row.label}</Text>
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
</View>
<Shortcut keys={row.keys} style={styles.rowShortcut} />
</View>
))}
</View>
</View>
))}
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
content: {
gap: theme.spacing[4],
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
rows: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.surface2,
},
rowText: {
flex: 1,
minWidth: 0,
},
rowLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
rowNote: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
rowShortcut: {
alignSelf: "flex-start",
},
}));

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,14 @@ 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";
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
@@ -117,6 +119,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 +147,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 +288,24 @@ 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,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
@@ -192,6 +321,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
export const UserMessage = memo(function UserMessage({
message,
images = [],
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
@@ -201,8 +331,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 +359,45 @@ 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.uri}-${index}`}
style={userMessageStylesheet.imagePill}
>
<Image
source={{ uri: image.uri }}
style={userMessageStylesheet.imageThumbnail}
/>
</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 +696,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 +1277,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 +1397,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 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Pressable>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
onHoverIn={() => onDetailHoverChange?.(true)}
onHoverOut={() => onDetailHoverChange?.(false)}

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}

File diff suppressed because it is too large Load Diff

View File

@@ -17,15 +17,24 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
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 { useSessionStore } from "@/stores/session-store";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDraftRoute,
buildHostAgentsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
@@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
const {
sections,
checkoutByAgentKey,
entries,
projectOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
} = useSidebarAgentsGrouped({
isOpen,
serverId: activeServerId,
selectedProjectKeys,
});
useEffect(() => {
setSelectedProjectKeys([]);
}, [activeServerId]);
const {
translateX,
backdropOpacity,
@@ -102,8 +120,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
isGesturing,
closeGestureRef,
} = useSidebarAnimation();
const trafficLightPadding = useTrafficLightPadding();
const dragHandlers = useTauriDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -120,11 +138,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}
}, [isRevalidating, isManualRefresh]);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys
);
const sidebarShortcutAgentKeys = useMemo(() => {
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sections]);
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
}, [entries]);
useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
@@ -135,11 +154,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent]);
const handleCreateAgentClean = useCallback(() => {
if (!activeServerId) {
let targetServerId = activeServerId;
let targetWorkingDir: string | null = null;
const selectedAgent = resolveSelectedAgentForNewAgent({
pathname,
selectedAgentId,
});
if (selectedAgent) {
targetServerId = selectedAgent.serverId;
const agent = useSessionStore
.getState()
.sessions[selectedAgent.serverId]
?.agents?.get(selectedAgent.agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
if (!targetServerId) {
return;
}
router.push(buildHostAgentDraftRoute(activeServerId) as any);
}, [activeServerId]);
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
}, [activeServerId, pathname, selectedAgentId]);
// Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => {
@@ -196,6 +238,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
windowWidth,
]);
const listFooterComponent = useMemo(() => {
if (!hasMoreEntries) {
return null;
}
return (
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
{({ hovered }) => (
<Text
style={[
styles.listViewMoreButtonText,
hovered && styles.listViewMoreButtonTextHovered,
]}
>
View more
</Text>
)}
</Pressable>
);
}, [handleViewMore, hasMoreEntries]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
@@ -262,8 +325,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}>
@@ -292,14 +356,44 @@ 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>
</>
)}
</Pressable>
</View>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
@@ -314,10 +408,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="All agents"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<Users
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
@@ -325,54 +456,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.footerButton}
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
All agents
</Text>
</>
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
</View>
</Animated.View>
</GestureDetector>
@@ -387,9 +470,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{/* Header: New Agent button - top padding area is draggable on Tauri */}
<View
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}>
@@ -400,14 +482,42 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
</View>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
@@ -422,47 +532,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Text>
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.footerButton}
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
All agents
</Text>
</>
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="All agents"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<Users
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -474,10 +561,24 @@ 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>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
</View>
);
@@ -510,9 +611,12 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
},
sidebarHeader: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
@@ -520,7 +624,7 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "flex-start",
gap: theme.spacing[2],
},
newAgentButton: {
@@ -543,12 +647,18 @@ const styles = StyleSheet.create((theme) => ({
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
maxWidth: "55%",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
hostTriggerHovered: {
borderColor: theme.colors.borderAccent,
},
hostStatusDot: {
width: 8,
@@ -558,6 +668,8 @@ const styles = StyleSheet.create((theme) => ({
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
@@ -568,28 +680,43 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHostSlot: {
flexGrow: 0,
flexShrink: 1,
minWidth: 0,
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
footerButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
footerButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
listViewMoreButton: {
marginTop: theme.spacing[2],
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[2],
},
listViewMoreButtonText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
footerButtonTextHovered: {
listViewMoreButtonTextHovered: {
color: theme.colors.foreground,
},
hostPickerList: {

View File

@@ -1,78 +1,62 @@
"use dom";
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
outputText: string;
initialOutputText: string;
outputChunkText: string;
outputChunkSequence: number;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
onInput?: (data: string) => Promise<void> | void;
onResize?: (rows: number, cols: number) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
}
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log(`[TerminalDebug][DOM] ${message}`, payload);
return;
}
console.log(`[TerminalDebug][DOM] ${message}`);
interface Window {}
}
export default function TerminalEmulator({
streamKey,
outputText,
initialOutputText,
outputChunkText,
outputChunkSequence,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
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);
const terminalRef = useRef<Terminal | null>(null);
const renderedOutputRef = useRef("");
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
useEffect(() => {
onInputRef.current = onInput;
}, [onInput]);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
useEffect(() => {
const host = hostRef.current;
@@ -81,302 +65,101 @@ export default function TerminalEmulator({
return;
}
logTerminalDebug("mount", {
streamKey,
hasOnInput: Boolean(onInputRef.current),
hasOnResize: Boolean(onResizeRef.current),
});
renderedOutputRef.current = "";
lastSizeRef.current = null;
host.innerHTML = "";
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 10_000,
theme: {
background: backgroundColor,
foreground: foregroundColor,
cursor: cursorColor,
const runtime = new TerminalEmulatorRuntime();
runtimeRef.current = runtime;
runtime.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(host);
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = root.parentElement;
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
const previousRootHeight = rootContainer?.style.height ?? "";
// Force document to follow WebView bounds; xterm viewport owns scrollback.
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
if (rootContainer) {
rootContainer.style.overflow = "hidden";
rootContainer.style.width = "100%";
rootContainer.style.height = "100%";
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "contain";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
terminalRef.current = terminal;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force = false) => {
const handler = onResizeRef.current;
if (!handler) {
return;
}
try {
fitAddon.fit();
} catch {
logTerminalDebug("fit failed");
return;
}
const rows = terminal.rows;
const cols = terminal.cols;
const previous = lastSizeRef.current;
if (!force && previous && previous.rows === rows && previous.cols === cols) {
return;
}
lastSizeRef.current = { rows, cols };
const rootRect = root.getBoundingClientRect();
logTerminalDebug("fit+resize", {
force,
rows,
cols,
rootWidth: Math.round(rootRect.width),
rootHeight: Math.round(rootRect.height),
});
void handler(rows, cols);
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
const handler = onInputRef.current;
if (!handler) {
return;
}
logTerminalDebug("input", {
length: data.length,
preview: data.slice(0, 20),
});
void handler(data);
runtime.setPendingModifiers({ pendingModifiers });
runtime.mount({
root,
host,
initialOutputText,
theme: {
backgroundColor,
foregroundColor,
cursorColor,
},
});
let lastScrollLogTs = 0;
let lastWheelLogTs = 0;
let lastTouchMoveLogTs = 0;
const viewportScrollHandler = () => {
const now = Date.now();
if (now - lastScrollLogTs < 120) {
return;
}
lastScrollLogTs = now;
logTerminalDebug("viewport scroll", {
baseY: terminal.buffer.active.baseY,
viewportY: terminal.buffer.active.viewportY,
});
};
const viewportWheelHandler = (event: WheelEvent) => {
const now = Date.now();
if (now - lastWheelLogTs < 120) {
return;
}
lastWheelLogTs = now;
logTerminalDebug("viewport wheel", {
deltaY: event.deltaY,
deltaX: event.deltaX,
});
};
const viewportTouchStartHandler = (event: TouchEvent) => {
logTerminalDebug("viewport touchstart", {
touches: event.touches.length,
});
};
const viewportTouchMoveHandler = (event: TouchEvent) => {
const now = Date.now();
if (now - lastTouchMoveLogTs < 120) {
return;
}
lastTouchMoveLogTs = now;
logTerminalDebug("viewport touchmove", {
touches: event.touches.length,
});
};
viewportElement?.addEventListener("scroll", viewportScrollHandler, { passive: true });
viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true });
viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, {
passive: true,
});
viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, {
passive: true,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize();
});
resizeObserver.observe(root);
const windowResizeHandler = () => fitAndEmitResize();
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize();
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
// Safety net for keyboard/layout transitions that can skip callbacks.
const fitInterval = window.setInterval(() => {
fitAndEmitResize();
}, 250);
window.setTimeout(() => fitAndEmitResize(true), 0);
if (outputText.length > 0) {
terminal.write(outputText);
renderedOutputRef.current = outputText;
}
terminal.focus();
return () => {
inputDisposable.dispose();
resizeObserver.disconnect();
window.removeEventListener("resize", windowResizeHandler);
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
window.clearInterval(fitInterval);
viewportElement?.removeEventListener("scroll", viewportScrollHandler);
viewportElement?.removeEventListener("wheel", viewportWheelHandler);
viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler);
viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler);
fitAddon.dispose();
terminal.dispose();
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
rootContainer.style.width = previousRootWidth;
rootContainer.style.height = previousRootHeight;
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
if (viewportElement) {
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
viewportElement.style.touchAction = previousViewportTouchAction;
viewportElement.style.overflowY = previousViewportOverflowY;
viewportElement.style.overflowX = previousViewportOverflowX;
viewportElement.style.pointerEvents = previousViewportPointerEvents;
viewportElement.style.setProperty(
"-webkit-overflow-scrolling",
previousViewportWebkitOverflowScrolling
);
}
terminalRef.current = null;
if (window.__paseoTerminal === terminal) {
window.__paseoTerminal = undefined;
}
logTerminalDebug("unmount", { streamKey });
renderedOutputRef.current = "";
lastSizeRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
runtimeRef.current?.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (outputChunkSequence <= 0) {
return;
}
const previous = renderedOutputRef.current;
if (outputText === previous) {
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
if (previous.length > 0 && outputText.startsWith(previous)) {
const suffix = outputText.slice(previous.length);
if (suffix.length > 0) {
terminal.write(suffix);
}
} else {
terminal.reset();
terminal.clear();
if (outputText.length > 0) {
terminal.write(outputText);
}
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
renderedOutputRef.current = outputText;
}, [outputText]);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
}
runtimeRef.current?.focus();
}, [focusRequestToken]);
useEffect(() => {
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
return (
<div
ref={rootRef}
data-testid={testId}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
position: "relative",
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor,
@@ -384,8 +167,7 @@ export default function TerminalEmulator({
overscrollBehavior: "none",
}}
onPointerDown={() => {
logTerminalDebug("root pointerdown", { streamKey });
terminalRef.current?.focus();
runtimeRef.current?.focus();
}}
>
<div

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
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
? setupLog
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View
@@ -356,6 +393,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,61 +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;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
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({
@@ -73,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}>
@@ -92,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}
@@ -102,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}
@@ -134,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 ? (
@@ -146,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({
@@ -158,279 +205,354 @@ export function Combobox({
value,
onSelect,
onSearchQueryChange,
placeholder = "Search...",
searchable = true,
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',
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" ? "top-start" : "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);
}, [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 = 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 =
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
@@ -440,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)}
@@ -459,14 +582,17 @@ export function Combobox({
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
)}
</>
);
)
const content = children ?? (
const defaultContent = (
<>
{searchInput}
{optionsList}
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
</>
);
)
const content = children ?? defaultContent
if (isMobile) {
return (
@@ -494,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}
@@ -537,27 +661,44 @@ export function Combobox({
</ScrollView>
) : (
<>
{searchInput}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</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}
{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,
@@ -575,13 +716,13 @@ 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],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderRadius: 0,
...(IS_WEB
? {}
: {
@@ -600,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,
@@ -625,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],
@@ -639,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],
@@ -650,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopBackdrop: {
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
@@ -661,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

@@ -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;
@@ -19,21 +20,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

@@ -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,11 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
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,
@@ -19,14 +20,19 @@ import type {
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 { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
import type { ActiveConnection } from "./daemon-connections-context";
import {
useSessionStore,
type Agent,
type SessionState,
type DaemonConnectionSnapshot,
} from "@/stores/session-store";
@@ -51,7 +57,7 @@ export type {
const derivePendingPermissionKey = (
agentId: string,
request: AgentPermissionRequest
request: NotificationPermissionRequest
) => {
const fallbackId =
request.id ||
@@ -67,41 +73,8 @@ const derivePendingPermissionKey = (
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];
@@ -124,12 +97,12 @@ const mapConnectionState = (
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 +114,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" }
@@ -281,6 +220,7 @@ export function SessionProvider({
activeConnection,
daemonPublicKeyB64,
}: SessionProviderProps) {
const queryClient = useQueryClient();
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() =>
@@ -402,6 +342,7 @@ export function SessionProvider({
agentId: string;
reason: "finished" | "error" | "permission";
timestamp: string;
notification?: AgentAttentionNotificationPayload;
}) => {
const appState = appStateRef.current;
const session = useSessionStore.getState().sessions[serverId];
@@ -412,6 +353,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 +372,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]
@@ -551,6 +491,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)) {
@@ -605,11 +546,23 @@ export function SessionProvider({
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()) {
return prev;
}
const next = new Map(prev);
next.set(agent.id, agent);
return next;
});
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
// Update agentLastActivity slice (top-level)
setAgentLastActivity(agent.id, agent.lastActivityAt);
@@ -653,6 +606,7 @@ export function SessionProvider({
previousAgentStatusRef.current.set(agent.id, agent.status);
},
[
queryClient,
serverId,
setAgents,
setAgentLastActivity,
@@ -846,14 +800,7 @@ export function SessionProvider({
useEffect(() => {
if (!connectionSnapshot.isConnected) {
hasBootstrappedAgentUpdatesRef.current = false;
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
if (subscriptionId && client) {
try {
client.unsubscribeAgentUpdates(subscriptionId);
} catch {
// no-op
}
}
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
return;
}
@@ -862,26 +809,84 @@ export function SessionProvider({
}
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 });
}
let cancelled = false;
const requestedSubscriptionId = `app:${serverId}`;
// 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]);
const bootstrapAgentDirectory = async () => {
try {
const payload = await client.fetchAgents({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: requestedSubscriptionId },
});
if (cancelled) {
return;
}
agentUpdatesSubscriptionIdRef.current =
payload.subscriptionId ?? requestedSubscriptionId;
const nextAgents = new Map<string, Agent>();
const nextPendingPermissions = new Map<
string,
{ key: string; agentId: string; request: NotificationPermissionRequest }
>();
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const entry of payload.entries) {
const agent = {
...normalizeAgentSnapshot(entry.agent, serverId),
projectPlacement: entry.project,
};
nextAgents.set(agent.id, agent);
nextStatuses.set(agent.id, agent.status);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
nextPendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
previousAgentStatusRef.current = nextStatuses;
pendingAgentUpdatesRef.current.clear();
setAgents(serverId, nextAgents);
for (const agent of nextAgents.values()) {
setAgentLastActivity(agent.id, agent.lastActivityAt);
}
setPendingPermissions(serverId, nextPendingPermissions);
setInitializingAgents(serverId, new Map());
setHasHydratedAgents(serverId, true);
updateConnectionStatus(serverId, {
status: "online",
lastOnlineAt: new Date().toISOString(),
agentListReady: true,
});
} catch (err) {
if (cancelled) {
return;
}
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
console.error("[Session] fetchAgents bootstrap failed", { serverId, err });
}
};
void bootstrapAgentDirectory();
return () => {
cancelled = true;
};
}, [
connectionSnapshot.isConnected,
client,
serverId,
setAgentLastActivity,
setAgents,
setHasHydratedAgents,
setInitializingAgents,
setPendingPermissions,
updateConnectionStatus,
]);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
@@ -915,6 +920,7 @@ export function SessionProvider({
agentId,
reason: event.reason,
timestamp: event.timestamp,
notification: event.notification,
});
}
}
@@ -990,6 +996,7 @@ export function SessionProvider({
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities }
: {}),
@@ -1283,6 +1290,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)) {
@@ -1364,6 +1372,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);
@@ -1396,6 +1405,7 @@ export function SessionProvider({
}, [
client,
audioPlayer,
queryClient,
serverId,
setIsPlayingAudio,
setMessages,

View File

@@ -0,0 +1,291 @@
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 {
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 = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
)
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 type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
@@ -9,20 +10,43 @@ 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
@@ -32,12 +56,12 @@ export function useAgentCommandsQuery({
);
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

@@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const snapshot of agentsList) {
for (const { agent: snapshot } of agentsList.entries) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);

View File

@@ -56,7 +56,9 @@ export function useAllAgentsList(options?: {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents();
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
@@ -76,20 +78,23 @@ export function useAllAgentsList(options?: {
if (!serverId) {
return [];
}
const data = agentsQuery.data ?? [];
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (const snapshot of data) {
for (const entry of data) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
list.push(
toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
})
);
const aggregated = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
list.push(aggregated);
}
list.sort((left, right) => {

View File

@@ -0,0 +1,69 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-archive-agent";
describe("useArchiveAgent", () => {
it("tracks pending archive state in shared react-query cache", () => {
const queryClient = new QueryClient();
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(false);
__private__.setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
isArchiving: true,
});
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(true);
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-2",
})
).toBe(false);
__private__.setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
isArchiving: false,
});
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(false);
});
it("removes an archived agent from cached list payloads", () => {
const payload = {
entries: [
{ agent: { id: "agent-1" } },
{ agent: { id: "agent-2" } },
],
pageInfo: { hasMore: false },
};
const next = __private__.removeAgentFromListPayload(payload, "agent-1");
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
});
});

View File

@@ -0,0 +1,236 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
export interface ArchiveAgentInput {
serverId: string;
agentId: string;
}
type ArchiveAgentPendingState = Record<string, true>;
interface SetAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
isArchiving: boolean;
}
interface IsAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
}
interface AgentsListQueryData {
entries?: Array<{ agent?: { id?: string | null } | null } | null>;
}
function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim();
const agentId = input.agentId.trim();
if (!serverId || !agentId) {
return "";
}
return `${serverId}:${agentId}`;
}
function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
return (
queryClient.getQueryData<ArchiveAgentPendingState>(
ARCHIVE_AGENT_PENDING_QUERY_KEY
) ?? {}
);
}
function setAgentArchiving(input: SetAgentArchivingInput): void {
const key = toArchiveKey(input);
if (!key) {
return;
}
input.queryClient.setQueryData<ArchiveAgentPendingState>(
ARCHIVE_AGENT_PENDING_QUERY_KEY,
(current) => {
const state = current ?? {};
if (input.isArchiving) {
if (state[key]) {
return state;
}
return { ...state, [key]: true };
}
if (!state[key]) {
return state;
}
const next = { ...state };
delete next[key];
return next;
}
);
}
function isAgentArchiving(input: IsAgentArchivingInput): boolean {
const key = toArchiveKey(input);
if (!key) {
return false;
}
return Boolean(readPendingState(input.queryClient)[key]);
}
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
payload: T,
agentId: string
): T {
if (!payload || !Array.isArray(payload.entries) || !agentId) {
return payload;
}
const filtered = payload.entries.filter((entry) => entry?.agent?.id !== agentId);
if (filtered.length === payload.entries.length) {
return payload;
}
return {
...payload,
entries: filtered,
} as T;
}
function removeAgentFromCachedLists(
queryClient: QueryClient,
input: ArchiveAgentInput
): void {
const agentId = input.agentId.trim();
if (!agentId) {
return;
}
queryClient.setQueryData<AgentsListQueryData | undefined>(
["sidebarAgentsList", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
);
queryClient.setQueryData<AgentsListQueryData | undefined>(
["allAgents", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
);
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return;
}
const setAgents = useSessionStore.getState().setAgents;
setAgents(input.serverId, (prev) => {
const existing = prev.get(input.agentId);
if (!existing) {
return prev;
}
if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) {
return prev;
}
const next = new Map(prev);
next.set(input.agentId, {
...existing,
archivedAt,
});
return next;
});
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
isArchiving: false,
});
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const pendingQuery = useQuery({
queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY,
queryFn: async (): Promise<ArchiveAgentPendingState> => ({}),
initialData: {} as ArchiveAgentPendingState,
staleTime: Infinity,
gcTime: Infinity,
});
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) {
throw new Error("Daemon client not available");
}
return await client.archiveAgent(input.agentId);
},
onMutate: (input) => {
setAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
isArchiving: true,
});
},
onSuccess: (result, input) => {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: input.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(queryClient, input);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
},
onSettled: (_result, _error, input) => {
clearArchiveAgentPending({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
});
},
});
const archiveAgent = useCallback(
async (input: ArchiveAgentInput): Promise<void> => {
if (
isAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
})
) {
return;
}
await archiveMutation.mutateAsync(input);
},
[archiveMutation, queryClient]
);
const isArchivingAgent = useCallback(
(input: ArchiveAgentInput): boolean => {
const key = toArchiveKey(input);
if (!key) {
return false;
}
return Boolean((pendingQuery.data ?? {})[key]);
},
[pendingQuery.data]
);
return {
archiveAgent,
isArchivingAgent,
};
}
export const __private__ = {
toArchiveKey,
readPendingState,
setAgentArchiving,
isAgentArchiving,
removeAgentFromListPayload,
};

View File

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

View File

@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getAutocompleteFallbackIndex,
getAutocompleteNextIndex,
type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> {
isVisible: boolean;
options: readonly TOption[];
query: string;
onSelectOption: (option: TOption) => void;
onEscape?: () => void;
optionsPosition?: AutocompleteOptionsPosition;
}
interface UseAutocompleteResult {
selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>
): UseAutocompleteResult {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
useEffect(() => {
if (!input.isVisible) {
previousQueryRef.current = input.query;
setSelectedIndex(-1);
return;
}
const queryChanged = previousQueryRef.current !== input.query;
previousQueryRef.current = input.query;
setSelectedIndex((current) => {
if (input.options.length === 0) {
return -1;
}
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
);
if (queryChanged) {
return fallbackIndex;
}
if (current < 0 || current >= input.options.length) {
return fallbackIndex;
}
return current;
});
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
const onKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (!input.isVisible || input.options.length === 0) {
return false;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) =>
getAutocompleteNextIndex({
currentIndex: current,
itemCount: input.options.length,
key: "ArrowUp",
})
);
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) =>
getAutocompleteNextIndex({
currentIndex: current,
itemCount: input.options.length,
key: "ArrowDown",
})
);
return true;
}
if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
);
const resolvedIndex =
selectedIndex >= 0 && selectedIndex < input.options.length
? selectedIndex
: fallbackIndex;
const selectedOption = input.options[resolvedIndex];
if (selectedOption) {
input.onSelectOption(selectedOption);
}
return true;
}
if (event.key === "Escape" && input.onEscape) {
event.preventDefault();
input.onEscape();
return true;
}
return false;
},
[input, selectedIndex]
);
return {
selectedIndex,
onKeyPress,
};
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -126,9 +126,11 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const requestMessageInputAction = useKeyboardShortcutsStore(
(s) => s.requestMessageInputAction
);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -214,19 +216,19 @@ export function useCommandCenter() {
const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(agentKey(agent));
requestMessageInputAction({
agentKey: agentKey(agent),
kind: "focus",
});
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
},
[pathname, requestFocusChatInput, setOpen]
[pathname, requestMessageInputAction, setOpen]
);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
@@ -267,7 +269,10 @@ export function useCommandCenter() {
isFocused,
onTimeout: () => {
if (agentKeyFromPathname) {
requestFocusChatInput(agentKeyFromPathname);
requestMessageInputAction({
agentKey: agentKeyFromPathname,
kind: "focus",
});
}
},
});
@@ -283,7 +288,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [agentKeyFromPathname, open, requestFocusChatInput]);
}, [agentKeyFromPathname, open, requestMessageInputAction]);
useEffect(() => {
if (!open) return;

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getTauri } from "@/utils/tauri";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext) {
console.log("[DictationAudio][Web] Microphone preflight", {
secureContext,
currentOrigin,
isTauri,
hasMediaDevices:
typeof navigator !== "undefined" &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === "function",
});
if (!secureContext && !isTauri) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
}
if (!secureContext && isTauri) {
console.warn(
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
{ currentOrigin }
);
}
const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) {

View File

@@ -0,0 +1,138 @@
import { useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import {
Extrapolation,
interpolate,
runOnJS,
useSharedValue,
} from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
interface UseExplorerOpenGestureParams {
enabled: boolean;
onOpen: () => void;
}
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerOpenGesture] ${event}`, details);
}
export function useExplorerOpenGesture({
enabled,
onOpen,
}: UseExplorerOpenGestureParams) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useExplorerSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
return useMemo(
() =>
Gesture.Pan()
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Fail quickly on rightward or clearly vertical intent.
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logExplorerOpenGesture)("start", { enabled });
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
const newTranslateX = Math.max(
0,
Math.min(windowWidth, windowWidth + event.translationX)
);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3;
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
runOnJS(logExplorerOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
panelTranslateX: translateX.value,
windowWidth,
shouldOpenByPosition,
shouldOpenByVelocity,
shouldOpen,
});
if (shouldOpen) {
animateToOpen();
runOnJS(onOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
enabled,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
isGesturing,
onOpen,
touchStartX,
touchStartY,
]
);
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -34,6 +36,21 @@ function deriveFaviconStatus(
return "none";
}
function deriveMacDockBadgeCount(
agents: ReturnType<typeof useAggregatedAgents>["agents"]
): number | undefined {
const attentionCount = agents.filter(
(agent) =>
agent.requiresAttention &&
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
).length;
if (attentionCount > 0) {
return attentionCount;
}
return undefined;
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -73,9 +90,25 @@ function getSystemColorScheme(): ColorScheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
return;
}
try {
await tauriWindow.setBadgeCount(count);
} catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
}
}
export function useFaviconStatus() {
const { agents } = useAggregatedAgents();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
// Listen for system color scheme changes
useEffect(() => {
@@ -96,5 +129,11 @@ export function useFaviconStatus() {
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCount(agents);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme]);
}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
@@ -13,11 +14,71 @@ interface UseFileDropZoneReturn {
}
const IS_WEB = Platform.OS === "web";
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
};
type TauriDragDropPayload =
| {
type: "enter";
paths: string[];
}
| {
type: "over";
}
| {
type: "drop";
paths: string[];
}
| {
type: "leave";
};
type TauriDragDropEvent = {
payload: TauriDragDropPayload;
};
function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
if (extensionIndex < 0) {
return "";
}
return normalizedPath.slice(extensionIndex).toLowerCase();
}
function isImagePath(path: string): boolean {
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
}
function filePathToImageAttachment(path: string): ImageAttachment {
const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const convertFileSrc = getTauri()?.core?.convertFileSrc;
const uri =
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
return {
uri,
mimeType,
};
}
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -62,78 +123,167 @@ export function useFileDropZone({
useEffect(() => {
if (!IS_WEB) return;
const element = containerRef.current;
if (!element) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
function runCleanup(unlisten?: () => void | Promise<void>) {
if (didCleanup) return;
const cleanupFn = unlisten ?? cleanup;
if (!cleanupFn) return;
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
async function setupTauriDragDrop(): Promise<boolean> {
if (getTauri() === null) {
return false;
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
return false;
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
const unlisten = await tauriWindow.onDragDropEvent(
(event: TauriDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
if (!disabled) {
setIsDragging(true);
}
return;
}
// Drop always ends the current drag operation.
setIsDragging(false);
if (disabled) return;
const imagePaths = payload.paths.filter(isImagePath);
if (imagePaths.length === 0) {
return;
}
const attachments = imagePaths.map(filePathToImageAttachment);
onFilesDroppedRef.current(attachments);
}
);
onFilesDroppedRef.current(attachments);
if (disposed) {
runCleanup(unlisten);
return true;
}
cleanup = unlisten;
return true;
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
return false;
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
function setupDomDragDrop() {
const element = containerRef.current;
if (!element) {
return;
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
);
onFilesDroppedRef.current(attachments);
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
cleanup = () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
};
}
void (async () => {
const tauriListenersAttached = await setupTauriDragDrop();
if (disposed || tauriListenersAttached) {
return;
}
setupDomDragDrop();
})();
return () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
disposed = true;
runCleanup();
};
}, [disabled]);

View File

@@ -1,316 +0,0 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useSessionStore } from "@/stores/session-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
export function useGlobalKeyboardNav({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const isEditableTarget = (event: KeyboardEvent): boolean => {
const target = event.target;
if (!(target instanceof Element)) return false;
if ((target as HTMLElement).isContentEditable) return true;
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return false;
};
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const n = Number(code.slice("Digit".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
if (code.startsWith("Numpad")) {
const n = Number(code.slice("Numpad".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
};
const navigateToSidebarShortcut = (digit: number) => {
const state = useKeyboardNavStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
};
const navigateToNewAgent = () => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
targetServerId = serverId;
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
const lowerKey = key.toLowerCase();
if (key === "Alt" && !event.shiftKey) {
useKeyboardNavStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
}
// If shift is pressed while a modifier is held, hide the badges
if (key === "Shift") {
const state = useKeyboardNavStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyN = event.code === "KeyN" || lowerKey === "n";
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
// Note: intentionally works even when focus is inside an input/textarea.
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd/Ctrl+N: new agent (Tauri only)
// Note: intentionally works even when focus is inside an input/textarea.
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd+B: toggle sidebar
if (
isMod &&
(event.code === "KeyB" || lowerKey === "b")
) {
// The MessageInput already handles Cmd+B inside editable fields. If we also
// handle it globally, it can double-toggle and look like it "doesn't work".
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
isMod &&
(event.code === "Period" || key === ".")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+E: toggle explorer sidebar (only when an agent is selected)
if (
selectedAgentId &&
toggleFileExplorer &&
isMod &&
(event.code === "KeyE" || lowerKey === "e")
) {
// Same double-toggle issue as Cmd+B when focus is inside a text input.
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
selectedAgentId &&
toggleFileExplorer &&
event.ctrlKey &&
!event.metaKey &&
(event.code === "Backquote" || key === "`")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Cmd+K: command center
if (isMod && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const target =
event.target instanceof Element ? (event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}
// Number switching: ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
const digit = parseShortcutDigit(event);
if (!digit) {
return;
}
// Alt/Option+number: always (web + Tauri)
if (event.altKey) {
event.preventDefault();
navigateToSidebarShortcut(digit);
return;
}
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && isMod) {
event.preventDefault();
navigateToSidebarShortcut(digit);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}

View File

@@ -0,0 +1,292 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveSelectedAgentForNewAgent,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
} from "@/keyboard/actions";
import {
canToggleFileExplorerShortcut,
resolveSelectedOrRouteAgentKey,
} from "@/keyboard/keyboard-shortcut-routing";
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
export function useKeyboardShortcuts({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const navigateToSidebarShortcut = (digit: number): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return false;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return false;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return true;
};
const navigateToNewAgent = (): boolean => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
const selectedAgent = resolveSelectedAgentForNewAgent({
pathname,
selectedAgentId,
});
if (selectedAgent) {
targetServerId = selectedAgent.serverId;
const agent = useSessionStore
.getState()
.sessions[selectedAgent.serverId]
?.agents?.get(selectedAgent.agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return false;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
return true;
};
const requestMessageInputAction = (
kind: MessageInputKeyboardActionKind
): boolean => {
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
if (!agentKey) {
return false;
}
useKeyboardShortcutsStore.getState().requestMessageInputAction({
agentKey,
kind,
});
return true;
};
const handleAction = (input: {
action: string;
payload: KeyboardShortcutPayload;
event: KeyboardEvent;
}): boolean => {
switch (input.action) {
case "agent.new":
return navigateToNewAgent();
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "sidebar.toggle.right":
if (!toggleFileExplorer) {
return false;
}
if (
!canToggleFileExplorerShortcut({
selectedAgentId,
pathname,
toggleFileExplorer,
})
) {
return false;
}
toggleFileExplorer();
return true;
case "sidebar.navigate.shortcut":
if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) {
return false;
}
return navigateToSidebarShortcut(input.payload.digit);
case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
const target =
input.event.target instanceof Element ? (input.event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
store.setCommandCenterOpen(!store.commandCenterOpen);
return true;
}
case "shortcuts.dialog.toggle": {
const store = useKeyboardShortcutsStore.getState();
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
return true;
}
case "message-input.action":
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
return false;
}
return requestMessageInputAction(input.payload.kind);
default:
return false;
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const match = resolveKeyboardShortcut({
event,
context: {
isMac,
isTauri,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: canToggleFileExplorerShortcut({
selectedAgentId,
pathname,
toggleFileExplorer,
}),
},
});
if (!match) {
return;
}
const handled = handleAction({
action: match.action,
payload: match.payload,
event,
});
if (!handled) {
return;
}
if (match.preventDefault) {
event.preventDefault();
}
if (match.stopPropagation) {
event.stopPropagation();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}

View File

@@ -1,53 +1,129 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
useSectionOrderStore,
sortProjectsByStoredOrder,
} from "@/stores/section-order-store";
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
deriveSidebarStateBucket,
isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages";
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
const MAX_AGENTS_PER_PROJECT = 5;
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
const SIDEBAR_DONE_FILL_TARGET = 50;
type SidebarGroupsPayload =
FetchAgentsGroupedByProjectResponseMessage["payload"];
export type SidebarCheckoutLite =
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
type MutableSidebarGroup = {
export interface SidebarProjectOption {
projectKey: string;
projectName: string;
agents: AggregatedAgent[];
};
activeCount: number;
totalCount: number;
serverId: string;
workingDir: string;
}
export interface SidebarSectionData {
key: string;
projectKey: string;
title: string;
agents: AggregatedAgent[];
firstAgentServerId?: string;
firstAgentId?: string;
workingDir?: string;
export interface SidebarAgentListEntry {
agent: AggregatedAgent & { createdAt: Date };
project: ProjectPlacementPayload;
}
export interface SidebarAgentsGroupedResult {
sections: SidebarSectionData[];
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
hasMoreEntries: boolean;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
refreshAll: () => void;
}
function compareByLastActivityDesc(
left: SidebarAgentListEntry,
right: SidebarAgentListEntry
): number {
return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime();
}
function compareByTitleAsc(
left: SidebarAgentListEntry,
right: SidebarAgentListEntry
): number {
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase();
const titleCmp = leftTitle.localeCompare(rightTitle, undefined, {
numeric: true,
sensitivity: "base",
});
if (titleCmp !== 0) {
return titleCmp;
}
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true,
sensitivity: "base",
});
}
function applySidebarDefaultOrdering(
entries: SidebarAgentListEntry[]
): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
const needsInput: SidebarAgentListEntry[] = [];
const failed: SidebarAgentListEntry[] = [];
const running: SidebarAgentListEntry[] = [];
const attention: SidebarAgentListEntry[] = [];
const done: SidebarAgentListEntry[] = [];
for (const entry of entries) {
const bucket = deriveSidebarStateBucket({
status: entry.agent.status,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
});
if (bucket === "needs_input") {
needsInput.push(entry);
continue;
}
if (bucket === "failed") {
failed.push(entry);
continue;
}
if (bucket === "running") {
running.push(entry);
continue;
}
if (bucket === "attention") {
attention.push(entry);
continue;
}
done.push(entry);
}
needsInput.sort(compareByLastActivityDesc);
failed.sort(compareByLastActivityDesc);
running.sort(compareByTitleAsc);
attention.sort(compareByLastActivityDesc);
done.sort(compareByLastActivityDesc);
const active = [...needsInput, ...failed, ...running, ...attention];
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
return { entries: active, hasMore: done.length > 0 };
}
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
const shownDone = done.slice(0, remainingDoneSlots);
return {
entries: [...active, ...shownDone],
hasMore: done.length > shownDone.length,
};
}
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
}): AggregatedAgent & { createdAt: Date } {
const source = params.source;
return {
id: source.id,
@@ -55,6 +131,7 @@ function toAggregatedAgent(params: {
serverLabel: params.serverLabel,
title: source.title ?? null,
status: source.status,
createdAt: source.createdAt,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
@@ -69,6 +146,7 @@ function toAggregatedAgent(params: {
export function useSidebarAgentsGrouped(options?: {
isOpen?: boolean;
serverId?: string | null;
selectedProjectKeys?: string[];
}): SidebarAgentsGroupedResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
@@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: {
? value.trim()
: null;
}, [options?.serverId]);
const selectedProjectKeys = useMemo(
() =>
new Set(
(options?.selectedProjectKeys ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
),
[options?.selectedProjectKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
@@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: {
const isConnected = session?.connection.isConnected ?? false;
const canFetch = Boolean(serverId && client && isConnected);
const groupedQuery = useQuery({
queryKey: ["sidebarAgentsGrouped", serverId] as const,
const agentsQuery = useQuery({
queryKey: ["sidebarAgentsList", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgentsGroupedByProject({
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "updated_at", direction: "desc" },
],
});
},
enabled: canFetch,
staleTime: SIDEBAR_GROUPS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
staleTime: SIDEBAR_AGENTS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId) {
return {
sections: [] as SidebarSectionData[],
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[],
hasAnyData: false,
hasMoreEntries: false,
};
}
const groupsByKey = new Map<string, MutableSidebarGroup>();
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
const seenAgentKeys = new Set<string>();
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
const groupedFetchReady = groupedQuery.isFetched;
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
if (payload) {
for (const group of payload.groups) {
const existing: MutableSidebarGroup =
groupsByKey.get(group.projectKey) ??
{
projectKey: group.projectKey,
projectName: group.projectName,
agents: [],
};
for (const entry of group.agents) {
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
const live = liveAgents?.get(entry.agent.id);
const nextAgent = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
if (nextAgent.archivedAt) {
continue;
}
const agentKey = `${serverId}:${entry.agent.id}`;
seenAgentKeys.add(agentKey);
checkoutLookup.set(
agentKey,
live?.projectPlacement?.checkout ?? entry.checkout
);
existing.agents.push(nextAgent);
}
groupsByKey.set(group.projectKey, existing);
const pushEntry = (entry: SidebarAgentListEntry): void => {
if (entry.agent.archivedAt) {
return;
}
const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`;
if (seenAgentIds.has(dedupeKey)) {
return;
}
seenAgentIds.add(dedupeKey);
mergedEntries.push(entry);
const existing = byProject.get(entry.project.projectKey);
const isActive = isSidebarActiveAgent({
status: entry.agent.status,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
});
if (existing) {
existing.totalCount += 1;
if (isActive) {
existing.activeCount += 1;
}
return;
}
byProject.set(entry.project.projectKey, {
projectKey: entry.project.projectKey,
projectName: entry.project.projectName,
activeCount: isActive ? 1 : 0,
totalCount: 1,
serverId,
workingDir: entry.project.checkout.cwd,
});
};
const fetchedEntries = agentsQuery.data?.entries ?? [];
for (const fetchedEntry of fetchedEntries) {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
continue;
}
const agent = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
pushEntry({ agent, project });
}
if (groupedFetchReady && liveAgents) {
if (liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
if (!live.projectPlacement) {
// Ignore fetchAgents-hydrated snapshots for sidebar placement.
// Sidebar should derive placement from grouped RPC or project-enriched agent_update.
continue;
}
const agentKey = `${serverId}:${live.id}`;
if (seenAgentKeys.has(agentKey)) {
continue;
}
const livePlacement = live.projectPlacement;
const projectKey = livePlacement.projectKey;
const existing: MutableSidebarGroup =
groupsByKey.get(projectKey) ??
{
projectKey,
projectName: livePlacement.projectName,
agents: [],
};
existing.agents.push(
toAggregatedAgent({
source: live,
serverId,
serverLabel,
})
);
checkoutLookup.set(agentKey, livePlacement.checkout);
groupsByKey.set(projectKey, existing);
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project: live.projectPlacement });
}
}
const sortedGroups = Array.from(groupsByKey.values())
.map((group) => {
const agents = [...group.agents].sort(
(left, right) =>
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
);
return {
...group,
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
};
})
.filter((group) => group.agents.length > 0)
.sort((left, right) => {
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
return rightRecent - leftRecent;
});
const filteredEntries =
selectedProjectKeys.size > 0
? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey)
)
: mergedEntries;
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
const nextSections = orderedGroups.map((group) => {
const firstAgent = group.agents[0];
return {
key: `project:${group.projectKey}`,
projectKey: group.projectKey,
title: group.projectName,
agents: group.agents,
firstAgentServerId: firstAgent?.serverId,
firstAgentId: firstAgent?.id,
workingDir: firstAgent?.cwd,
};
const ordered = applySidebarDefaultOrdering(filteredEntries);
const options = Array.from(byProject.values()).sort((left, right) => {
if (left.activeCount !== right.activeCount) {
return right.activeCount - left.activeCount;
}
return left.projectName.localeCompare(right.projectName);
});
return {
sections: nextSections,
checkoutByAgentKey: checkoutLookup,
hasAnyData: nextSections.length > 0,
entries: ordered.entries,
projectOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
}, [
agentsQuery.data?.entries,
connectionStates,
groupedQuery.data,
groupedQuery.isFetched,
liveAgents,
projectOrder,
selectedProjectKeys,
serverId,
]);
useEffect(() => {
const currentKeys = sections.map((section) => section.projectKey);
const storedKeys = new Set(projectOrder);
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
if (newKeys.length > 0) {
setProjectOrder([...projectOrder, ...newKeys]);
}
}, [sections, projectOrder, setProjectOrder]);
const refreshAll = useCallback(() => {
if (!serverId) {
return;
}
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsGrouped", serverId],
queryKey: ["sidebarAgentsList", serverId],
});
}, [queryClient, serverId]);
const isFetching =
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
return {
sections,
checkoutByAgentKey,
entries,
projectOptions,
hasMoreEntries,
isLoading: isFetching,
isInitialLoad,
isRevalidating,
refreshAll,
};
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SpeechSegmenter } from "@/voice/speech-segmenter";
import { getTauri } from "@/utils/tauri";
export interface SpeechmaticsAudioConfig {
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
@@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat
? window.isSecureContext
: true;
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
try {
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext) {
console.log("[Voice][Web] Microphone preflight", {
secureContext,
currentOrigin,
isTauri,
hasMediaDevices:
typeof navigator !== "undefined" &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === "function",
});
if (!secureContext && !isTauri) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
}
if (!secureContext && isTauri) {
console.warn(
"[Voice][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
{ currentOrigin }
);
}
const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) {

View File

@@ -0,0 +1,27 @@
export type KeyboardFocusScope =
| "terminal"
| "message-input"
| "command-center"
| "editable"
| "other";
export type MessageInputKeyboardActionKind =
| "focus"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
| "voice-mute-toggle";
export type KeyboardActionId =
| "agent.new"
| "sidebar.toggle.left"
| "sidebar.toggle.right"
| "sidebar.navigate.shortcut"
| "command-center.toggle"
| "shortcuts.dialog.toggle"
| "message-input.action";
export type KeyboardShortcutPayload =
| { digit: number }
| { kind: MessageInputKeyboardActionKind }
| null;

View File

@@ -0,0 +1,42 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
return commandCenterOpen ? "command-center" : "other";
}
if (
target.closest("[data-testid='terminal-surface']") ||
target.closest(".xterm")
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
return commandCenterOpen ? "command-center" : "editable";
}
return commandCenterOpen ? "command-center" : "other";
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { canToggleFileExplorerShortcut } from "./keyboard-shortcut-routing";
describe("keyboard-shortcut-routing", () => {
describe("canToggleFileExplorerShortcut", () => {
const toggleFileExplorer = () => undefined;
it("allows the shortcut on selected-agent routes", () => {
const canToggle = canToggleFileExplorerShortcut({
selectedAgentId: "server-1:agent-1",
pathname: "/h/server-1/agent/agent-1",
toggleFileExplorer,
});
expect(canToggle).toBe(true);
});
it("allows the shortcut on draft routes", () => {
const canToggle = canToggleFileExplorerShortcut({
pathname: "/h/server-1/agent",
toggleFileExplorer,
});
expect(canToggle).toBe(true);
});
it("blocks the shortcut when no toggle handler exists", () => {
const canToggle = canToggleFileExplorerShortcut({
pathname: "/h/server-1/agent",
});
expect(canToggle).toBe(false);
});
it("blocks the shortcut outside agent routes", () => {
const canToggle = canToggleFileExplorerShortcut({
pathname: "/h/server-1/settings",
toggleFileExplorer,
});
expect(canToggle).toBe(false);
});
});
});

View File

@@ -0,0 +1,40 @@
import {
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
const DRAFT_AGENT_ID = "__new_agent__";
export function resolveSelectedOrRouteAgentKey(input: {
selectedAgentId?: string;
pathname: string;
}): string | null {
if (input.selectedAgentId) {
return input.selectedAgentId;
}
const route = parseHostAgentRouteFromPathname(input.pathname);
if (!route) {
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
if (!draftRoute) {
return null;
}
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
}
return `${route.serverId}:${route.agentId}`;
}
export function canToggleFileExplorerShortcut(input: {
selectedAgentId?: string;
pathname: string;
toggleFileExplorer?: () => void;
}): boolean {
if (!input.toggleFileExplorer) {
return false;
}
return (
resolveSelectedOrRouteAgentKey({
selectedAgentId: input.selectedAgentId,
pathname: input.pathname,
}) !== null
);
}

View File

@@ -0,0 +1,233 @@
import { describe, expect, it } from "vitest";
import {
buildKeyboardShortcutHelpSections,
resolveKeyboardShortcut,
type KeyboardShortcutContext,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: "",
code: "",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
...overrides,
} as KeyboardEvent;
}
function shortcutContext(
overrides: Partial<KeyboardShortcutContext> = {}
): KeyboardShortcutContext {
return {
isMac: false,
isTauri: false,
focusScope: "other",
commandCenterOpen: false,
hasSelectedAgent: true,
...overrides,
};
}
describe("keyboard-shortcuts", () => {
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "?",
code: "Slash",
shiftKey: true,
}),
context: shortcutContext({ focusScope: "other" }),
});
expect(match?.action).toBe("shortcuts.dialog.toggle");
});
it("does not match question-mark shortcut inside editable scopes", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "?",
code: "Slash",
shiftKey: true,
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match).toBeNull();
});
it("matches Cmd+B sidebar toggle on macOS", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
metaKey: true,
}),
context: shortcutContext({ isMac: true }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("does not bind Ctrl+B on non-mac", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match).toBeNull();
});
it("keeps Mod+. as sidebar toggle fallback", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: ".",
code: "Period",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("routes Mod+D to message-input action outside terminal", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
});
it("does not route message-input actions when terminal is focused", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
});
expect(match).toBeNull();
});
it("keeps space typing available in message input", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: " ",
code: "Space",
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match).toBeNull();
});
it("routes space to voice mute toggle outside editable scopes", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: " ",
code: "Space",
}),
context: shortcutContext({ focusScope: "other" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
});
it("lets Escape continue to local handlers while routing dictation cancel", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "Escape",
code: "Escape",
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "dictation-cancel" });
expect(match?.preventDefault).toBe(false);
expect(match?.stopPropagation).toBe(false);
});
it("parses Alt+digit sidebar shortcut payload", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "2",
code: "Digit2",
altKey: true,
}),
context: shortcutContext(),
});
expect(match?.action).toBe("sidebar.navigate.shortcut");
expect(match?.payload).toEqual({ digit: 2 });
});
});
describe("keyboard-shortcut help sections", () => {
function findRow(
sections: ReturnType<typeof buildKeyboardShortcutHelpSections>,
id: string
) {
for (const section of sections) {
const row = section.rows.find((candidate) => candidate.id === id);
if (row) {
return row;
}
}
return null;
}
it("uses non-tauri defaults for new-agent and quick-open", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: true,
isTauri: false,
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
"alt",
"1-9",
]);
});
it("switches to tauri bindings in help rows", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: true,
isTauri: true,
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
"mod",
"1-9",
]);
});
it("uses mod+period as non-mac left sidebar shortcut", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: false,
isTauri: false,
});
expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([
"mod",
".",
]);
});
});

View File

@@ -0,0 +1,423 @@
import type { ShortcutKey } from "@/utils/format-shortcut";
import type {
KeyboardActionId,
KeyboardFocusScope,
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
export type KeyboardShortcutContext = {
isMac: boolean;
isTauri: boolean;
focusScope: KeyboardFocusScope;
commandCenterOpen: boolean;
hasSelectedAgent: boolean;
};
export type KeyboardShortcutMatch = {
action: KeyboardActionId;
payload: KeyboardShortcutPayload;
preventDefault: boolean;
stopPropagation: boolean;
};
export type KeyboardShortcutHelpRow = {
id: string;
label: string;
keys: ShortcutKey[];
note?: string;
};
export type KeyboardShortcutHelpSection = {
id: "global" | "agent-input";
title: string;
rows: KeyboardShortcutHelpRow[];
};
type KeyboardShortcutPlatformContext = {
isMac: boolean;
isTauri: boolean;
};
type KeyboardShortcutHelpEntry = {
id: string;
section: KeyboardShortcutHelpSection["id"];
label: string;
keys: ShortcutKey[];
note?: string;
when?: (context: KeyboardShortcutPlatformContext) => boolean;
};
type KeyboardShortcutBinding = {
id: string;
action: KeyboardActionId;
matches: (event: KeyboardEvent) => boolean;
when: (context: KeyboardShortcutContext) => boolean;
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
preventDefault?: boolean;
stopPropagation?: boolean;
help?: KeyboardShortcutHelpEntry;
};
const SHORTCUT_HELP_SECTION_TITLES: Record<
KeyboardShortcutHelpSection["id"],
string
> = {
global: "Global",
"agent-input": "Agent Input",
};
function isMod(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey;
}
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const value = Number(code.slice("Digit".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
if (code.startsWith("Numpad")) {
const value = Number(code.slice("Numpad".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
}
function hasDigit(event: KeyboardEvent): boolean {
return parseDigit(event) !== null;
}
function isQuestionMarkShortcut(event: KeyboardEvent): boolean {
return (
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.shiftKey &&
!event.repeat &&
(event.key === "?" || event.code === "Slash")
);
}
function withMessageInputAction(
kind: MessageInputKeyboardActionKind
): (event: KeyboardEvent) => KeyboardShortcutPayload {
return () => ({ kind });
}
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
{
id: "agent-new-mod-alt-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: () => true,
help: {
id: "new-agent",
section: "global",
label: "Create new agent",
keys: ["mod", "alt", "N"],
when: (context) => !context.isTauri,
},
},
{
id: "agent-new-tauri-mod-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: (context) => context.isTauri,
help: {
id: "new-agent",
section: "global",
label: "Create new agent",
keys: ["mod", "N"],
when: (context) => context.isTauri,
},
},
{
id: "command-center-toggle",
action: "command-center.toggle",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
when: () => true,
help: {
id: "toggle-command-center",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
},
{
id: "shortcuts-dialog-toggle-question-mark",
action: "shortcuts.dialog.toggle",
matches: isQuestionMarkShortcut,
when: (context) => context.focusScope === "other",
help: {
id: "show-shortcuts",
section: "global",
label: "Show keyboard shortcuts",
keys: ["?"],
note: "Available when focus is not in a text field or terminal.",
},
},
{
id: "sidebar-toggle-left-mac-cmd-b",
action: "sidebar.toggle.left",
matches: (event) =>
event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
when: (context) => context.isMac,
help: {
id: "toggle-left-sidebar",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "B"],
when: (context) => context.isMac,
},
},
{
id: "sidebar-toggle-left-mod-period",
action: "sidebar.toggle.left",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Period" || event.key === "."),
when: (context) => !context.commandCenterOpen,
help: {
id: "toggle-left-sidebar",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "."],
when: (context) => !context.isMac,
},
},
{
id: "sidebar-toggle-right-mod-e",
action: "sidebar.toggle.right",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
help: {
id: "toggle-right-sidebar",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
},
{
id: "sidebar-toggle-right-ctrl-backquote",
action: "sidebar.toggle.right",
matches: (event) =>
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Backquote" || event.key === "`"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
},
{
id: "message-input-voice-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d") &&
!event.repeat,
payload: withMessageInputAction("voice-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
help: {
id: "voice-toggle",
section: "agent-input",
label: "Toggle voice mode",
keys: ["mod", "shift", "D"],
},
},
{
id: "message-input-dictation-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
!event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d"),
payload: withMessageInputAction("dictation-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
help: {
id: "dictation-toggle",
section: "agent-input",
label: "Start/stop dictation",
keys: ["mod", "D"],
},
},
{
id: "message-input-dictation-cancel",
action: "message-input.action",
matches: (event) => event.key === "Escape",
payload: withMessageInputAction("dictation-cancel"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
preventDefault: false,
stopPropagation: false,
help: {
id: "dictation-cancel",
section: "agent-input",
label: "Cancel dictation",
keys: ["Esc"],
},
},
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",
matches: (event) =>
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat,
payload: withMessageInputAction("voice-mute-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope === "other",
help: {
id: "voice-mute-toggle",
section: "agent-input",
label: "Mute/unmute voice mode",
keys: ["Space"],
},
},
{
id: "sidebar-shortcut-alt-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => event.altKey && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => !context.commandCenterOpen,
help: {
id: "quick-open-agent",
section: "global",
label: "Open sidebar agent shortcut",
keys: ["alt", "1-9"],
when: (context) => !context.isTauri,
},
},
{
id: "sidebar-shortcut-tauri-mod-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => isMod(event) && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => context.isTauri && !context.commandCenterOpen,
help: {
id: "quick-open-agent",
section: "global",
label: "Open sidebar agent shortcut",
keys: ["mod", "1-9"],
when: (context) => context.isTauri,
},
},
];
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
}): KeyboardShortcutMatch | null {
const { event, context } = input;
for (const binding of SHORTCUT_BINDINGS) {
if (!binding.matches(event)) {
continue;
}
if (!binding.when(context)) {
continue;
}
const payload = binding.payload?.(event) ?? null;
return {
action: binding.action,
payload,
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
return null;
}
export function buildKeyboardShortcutHelpSections(
input: KeyboardShortcutPlatformContext
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
["global", []],
["agent-input", []],
]);
for (const binding of SHORTCUT_BINDINGS) {
const help = binding.help;
if (!help) {
continue;
}
if (help.when && !help.when(input)) {
continue;
}
const rowKey = `${help.section}:${help.id}`;
if (seenRows.has(rowKey)) {
continue;
}
seenRows.add(rowKey);
const rows = rowsBySection.get(help.section);
if (!rows) {
continue;
}
rows.push({
id: help.id,
label: help.label,
keys: help.keys,
...(help.note ? { note: help.note } : {}),
});
}
const sectionOrder: KeyboardShortcutHelpSection["id"][] = [
"global",
"agent-input",
];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
if (rows.length === 0) {
return [];
}
return [
{
id: sectionId,
title: SHORTCUT_HELP_SECTION_TITLES[sectionId],
rows,
},
];
});
}

View File

@@ -13,14 +13,12 @@ import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
MoreVertical,
@@ -40,7 +38,6 @@ import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -59,14 +56,23 @@ import { extractAgentModel } from "@/utils/extract-agent-model";
import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
useCheckoutStatusQuery,
} from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useToast } from "@/contexts/toast-context";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import {
DropdownMenu,
DropdownMenuContent,
@@ -75,11 +81,20 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const RECONNECT_NOTICE_DELAY_MS = 10_000;
const CONNECTED_NOTICE_DURATION_MS = 2_500;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[AgentExplorer] ${event}`, details);
}
export function AgentReadyScreen({
serverId,
@@ -210,6 +225,9 @@ function AgentScreenContent({
const toast = useToast();
const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -229,31 +247,109 @@ function AgentScreenContent({
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout
);
// Derive isExplorerOpen from the unified panel state
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const openExplorerWithDefaultTab = useCallback(() => {
// Generic explorer toggles should land on Changes by default.
setExplorerTab("changes");
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
// Checkout status for header subtitle + git fallback when cached project placement is absent
const checkoutStatusQuery = useCheckoutStatusQuery({
serverId,
cwd: agent?.cwd ?? "",
});
const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback(
(params: {
agentId?: string | null;
cwd?: string | null;
projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean;
}): boolean | null => {
if (typeof params.projectPlacementIsGit === "boolean") {
return params.projectPlacementIsGit;
}
const agentId = params.agentId?.trim();
if (agentId) {
const sidebarAgents = queryClient.getQueryData<{
entries: FetchAgentsEntry[];
}>(["sidebarAgentsList", serverId]);
const sidebarIsGit = sidebarAgents?.entries.find(
(entry) => entry.agent.id === agentId
)?.project?.checkout?.isGit;
if (typeof sidebarIsGit === "boolean") {
return sidebarIsGit;
}
}
const cwd = params.cwd?.trim();
if (!cwd) {
return null;
}
const cachedCheckout = queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
);
if (typeof cachedCheckout?.isGit === "boolean") {
return cachedCheckout.isGit;
}
if (typeof params.checkoutStatusIsGit === "boolean") {
return params.checkoutStatusIsGit;
}
return null;
},
[queryClient, serverId]
);
const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => {
if (!resolvedAgentId) {
return null;
}
const currentAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
});
if (!cwd || typeof isGit !== "boolean") {
return null;
}
return { serverId, cwd, isGit };
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
const openExplorerForActiveCheckout = useCallback(() => {
const checkoutContext = resolveCurrentExplorerCheckout();
logAgentExplorer("openExplorerForActiveCheckout", {
hasCheckoutContext: Boolean(checkoutContext),
checkoutContext,
});
if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext);
}
openFileExplorer();
}, [openFileExplorer, setExplorerTab]);
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
const handleToggleExplorer = useCallback(() => {
logAgentExplorer("handleToggleExplorer", {
isExplorerOpen,
mobileView,
isMobile,
});
if (isExplorerOpen) {
toggleFileExplorer();
return;
}
openExplorerWithDefaultTab();
}, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]);
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
openExplorerForActiveCheckout();
}, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]);
useEffect(() => {
if (Platform.OS !== "web") {
@@ -265,55 +361,10 @@ function AgentScreenContent({
}, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen)
// Only activate after 15px horizontal movement to the left (negative)
.activeOffsetX(-15)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isExplorerGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0
// Swiping left means negative translationX
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
explorerTranslateX.value = newTranslateX;
explorerBackdropOpacity.value = interpolate(
newTranslateX,
[explorerWindowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isExplorerGesturing.value = false;
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
animateExplorerToOpen();
runOnJS(openExplorerWithDefaultTab)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToOpen,
animateExplorerToClose,
openExplorerWithDefaultTab,
isExplorerGesturing,
]
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent",
onOpen: openExplorerForActiveCheckout,
});
// Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => {
@@ -330,14 +381,43 @@ function AgentScreenContent({
return () => handler.remove();
}, [isExplorerOpen, closeToAgent]);
const resolvedAgentId = agentId;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
});
if (!cwd || typeof isGit !== "boolean") {
return null;
}
return { serverId, cwd, isGit };
}, [
agent?.cwd,
agent?.projectPlacement?.checkout?.isGit,
resolveCachedCheckoutIsGit,
resolvedAgentId,
checkout?.isGit,
serverId,
]);
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
useEffect(() => {
setActiveExplorerCheckout(activeExplorerCheckout);
}, [activeExplorerCheckout, setActiveExplorerCheckout]);
useEffect(() => {
if (!activeExplorerCheckout) {
return;
}
activateExplorerTabForCheckout(activeExplorerCheckout);
}, [activateExplorerTabForCheckout, activeExplorerCheckout]);
useEffect(() => {
return () => {
setActiveExplorerCheckout(null);
};
}, [setActiveExplorerCheckout]);
// Select only the specific stream tail - use stable empty array to avoid infinite loop
const streamItemsRaw = useSessionStore((state) =>
@@ -374,6 +454,7 @@ function AgentScreenContent({
(state) => state.sessions[serverId]?.pendingPermissions
);
const setAgents = useSessionStore((state) => state.setAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
@@ -394,6 +475,9 @@ function AgentScreenContent({
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
kind: "idle",
@@ -480,12 +564,11 @@ function AgentScreenContent({
};
}, [showConnectedNotice]);
// Checkout status for header subtitle
const checkoutStatusQuery = useCheckoutStatusQuery({
serverId,
cwd: agent?.cwd ?? "",
});
const checkout = checkoutStatusQuery.status;
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
const isArchivingCurrentAgent = Boolean(
resolvedAgentId &&
isArchivingAgent({ serverId, agentId: resolvedAgentId })
);
useEffect(() => {
if (!resolvedAgentId) {
@@ -524,6 +607,9 @@ function AgentScreenContent({
id: pendingCreate.messageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [isPendingCreateForRoute, pendingCreate]);
@@ -605,6 +691,32 @@ function AgentScreenContent({
(item.id === pendingCreate.messageId || item.text === pendingCreate.text)
);
if (agent && hasUserMessage) {
if (
resolvedAgentId &&
pendingCreate.images &&
pendingCreate.images.length > 0
) {
setAgentStreamTail(serverId, (prev) => {
const current = prev.get(resolvedAgentId);
if (!current) {
return prev;
}
const merged = mergePendingCreateImages({
streamItems: current,
messageId: pendingCreate.messageId,
text: pendingCreate.text,
images: pendingCreate.images,
});
if (merged === current) {
return prev;
}
const next = new Map(prev);
next.set(resolvedAgentId, merged);
return next;
});
}
clearPendingCreate();
}
}, [
@@ -612,6 +724,9 @@ function AgentScreenContent({
clearPendingCreate,
isPendingCreateForRoute,
pendingCreate,
resolvedAgentId,
serverId,
setAgentStreamTail,
streamItems,
]);
@@ -747,25 +862,30 @@ function AgentScreenContent({
document.title = title;
}, [agent?.title]);
// Track previous agent status to detect completion while viewing
const previousStatusRef = useRef<string | null>(null);
// Clear attention when agent finishes while user is viewing this screen
// Clear attention as soon as the user is focused on this agent screen.
useEffect(() => {
if (!resolvedAgentId || !agent || !client) {
const clearAgentId = resolvedAgentId?.trim();
if (!clearAgentId || !client) {
return;
}
const previousStatus = previousStatusRef.current;
const currentStatus = agent.status;
previousStatusRef.current = currentStatus;
// If agent transitioned from running to idle while we're viewing,
// immediately clear attention since user witnessed the completion
if (previousStatus === "running" && currentStatus === "idle") {
client.clearAgentAttention(resolvedAgentId);
if (
!shouldClearAgentAttentionOnView({
agentId: clearAgentId,
focusedAgentId,
isConnected,
requiresAttention: agent?.requiresAttention,
})
) {
return;
}
}, [resolvedAgentId, agent?.status, client]);
client.clearAgentAttention(clearAgentId);
}, [
agent?.requiresAttention,
client,
focusedAgentId,
isConnected,
resolvedAgentId,
]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId) {
@@ -785,7 +905,7 @@ function AgentScreenContent({
await Clipboard.setStringAsync(value);
toast.show(`Copied ${label}`, {
variant: "success",
icon: <CheckCircle2 size={16} color={theme.colors.primary} />,
icon: <CheckCircle2 size={theme.iconSize.md} color={theme.colors.primary} />,
});
} catch {
toast.error("Copy failed");
@@ -829,7 +949,10 @@ function AgentScreenContent({
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}>
<FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
>
<View style={styles.container}>
{/* Header */}
<MenuHeader
@@ -850,7 +973,7 @@ function AgentScreenContent({
{isMobile ? (
checkout?.isGit ? (
<GitBranch
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -859,7 +982,7 @@ function AgentScreenContent({
/>
) : (
<Folder
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -869,7 +992,7 @@ function AgentScreenContent({
)
) : (
<PanelRight
size={16}
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
@@ -886,7 +1009,10 @@ function AgentScreenContent({
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={isMobile ? 20 : 16} color={theme.colors.foregroundMuted} />
<MoreVertical
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
@@ -1005,7 +1131,7 @@ function AgentScreenContent({
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
disabled={isInitializing || shouldBlockForHistorySync}
trailing={
isInitializing ? (
@@ -1037,7 +1163,7 @@ function AgentScreenContent({
>
{showConnectedNotice ? (
<CheckCircle2
size={14}
size={theme.iconSize.sm}
color={theme.colors.palette.green[600]}
/>
) : null}
@@ -1091,7 +1217,10 @@ function AgentScreenContent({
</View>
{/* Agent Input Area */}
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
{agent &&
resolvedAgentId &&
!shouldBlockForHistorySync &&
!isArchivingCurrentAgent && (
<AgentInputArea
agentId={resolvedAgentId}
serverId={serverId}
@@ -1105,8 +1234,23 @@ function AgentScreenContent({
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>
Please wait while we archive this agent.
</Text>
</View>
) : null}
</View>
);
@@ -1122,7 +1266,12 @@ function AgentScreenContent({
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
</>
);
@@ -1263,6 +1412,30 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
gap: 16,
},
archivingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(8, 10, 14, 0.86)",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[8],
gap: theme.spacing[3],
zIndex: 50,
},
archivingTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
archivingSubtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,13 @@ import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { theme as defaultTheme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
@@ -27,6 +27,7 @@ import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
DropdownMenu,
DropdownMenuContent,
@@ -34,6 +35,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
shouldShowDesktopPermissionSection,
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
type DesktopPermissionStatus,
} from "@/utils/desktop-permissions";
const delay = (ms: number) =>
new Promise<void>((resolve) => {
@@ -75,8 +84,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.6,
textTransform: "uppercase",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
@@ -84,8 +91,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.4,
textTransform: "uppercase",
marginBottom: theme.spacing[2],
},
input: {
@@ -180,8 +185,30 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
hostCardPressed: {
opacity: 0.85,
versionPill: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 200,
},
hostSettingsButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: "transparent",
backgroundColor: "transparent",
marginLeft: theme.spacing[2],
},
hostSettingsButtonActive: {
backgroundColor: theme.colors.surface3,
},
advancedTrigger: {
flexDirection: "row",
@@ -291,39 +318,58 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
audioRowDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: 2,
},
// Footer
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
permissionSectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
footerAppInfo: {
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],
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
footerText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
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",
},
footerVersion: {
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",
},
resetButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
resetButtonText: {
color: theme.colors.palette.red[500],
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
// Empty state
emptyCard: {
@@ -339,35 +385,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
textAlign: "center",
},
// Theme toggle
themeToggleContainer: {
flexDirection: "row",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: 4,
gap: 4,
},
themeToggleButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
themeToggleButtonActive: {
backgroundColor: theme.colors.surface3,
},
themeToggleText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
themeToggleTextActive: {
color: theme.colors.foreground,
},
// Dev section
devCard: {
backgroundColor: theme.colors.surface2,
@@ -401,13 +418,37 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function resolveAppVersion(): string | null {
const expoVersion = Constants.expoConfig?.version;
if (typeof expoVersion === "string" && expoVersion.trim().length > 0) {
return expoVersion.trim();
}
const manifestVersion = (Constants as any).manifest?.version;
if (typeof manifestVersion === "string" && manifestVersion.trim().length > 0) {
return manifestVersion.trim();
}
return null;
}
function formatDaemonVersionBadge(version: string | null): string | null {
const daemonVersion = version?.trim();
if (!daemonVersion) {
return null;
}
if (daemonVersion.startsWith("v")) {
return daemonVersion;
}
return `v${daemonVersion}`;
}
export default function SettingsScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const {
daemons,
isLoading: daemonLoading,
@@ -426,10 +467,17 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
useState(false);
const [requestingDesktopPermission, setRequestingDesktopPermission] =
useState<DesktopPermissionKind | null>(null);
const isLoading = settingsLoading || daemonLoading;
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
const appVersion = resolveAppVersion();
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
@@ -524,6 +572,73 @@ export default function SettingsScreen() {
pendingEditReopenServerId,
]);
const refreshDesktopPermissions = useCallback(async () => {
if (!showDesktopPermissionSection) return;
setIsRefreshingDesktopPermissions(true);
try {
const snapshot = await getDesktopPermissionSnapshot();
if (!isMountedRef.current) return;
setDesktopPermissionSnapshot(snapshot);
} catch (error) {
console.error("[Settings] Failed to load desktop permission status", error);
} finally {
if (isMountedRef.current) {
setIsRefreshingDesktopPermissions(false);
}
}
}, [showDesktopPermissionSection]);
const handleRequestDesktopPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!showDesktopPermissionSection) return;
setRequestingDesktopPermission(kind);
try {
const status = await requestDesktopPermission({ kind });
if (!isMountedRef.current) return;
setDesktopPermissionSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: {
state: "unknown",
detail: "Notification status has not been checked yet.",
},
microphone: {
state: "unknown",
detail: "Microphone status has not been checked yet.",
},
};
return kind === "notifications"
? {
...base,
checkedAt: Date.now(),
notifications: status,
}
: {
...base,
checkedAt: Date.now(),
microphone: status,
};
});
} catch (error) {
console.error(`[Settings] Failed to request ${kind} permission`, error);
} finally {
if (isMountedRef.current) {
setRequestingDesktopPermission(null);
}
await refreshDesktopPermissions();
}
},
[refreshDesktopPermissions, showDesktopPermissionSection]
);
useEffect(() => {
if (!showDesktopPermissionSection) return;
void refreshDesktopPermissions();
}, [refreshDesktopPermissions, showDesktopPermissionSection]);
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingServerId) return;
if (isSavingEdit) return;
@@ -580,34 +695,6 @@ export default function SettingsScreen() {
[updateSettings]
);
function handleReset() {
Alert.alert(
"Reset settings",
"Are you sure you want to reset all settings to defaults?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
try {
await resetSettings();
Alert.alert(
"Settings reset",
"All settings have been reset to defaults."
);
} catch (error) {
Alert.alert(
"Error",
"Failed to reset settings. Please try again."
);
}
},
},
]
);
}
const restartConfirmationMessage =
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
@@ -646,7 +733,7 @@ export default function SettingsScreen() {
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onPress={handleEditDaemon}
onOpenSettings={handleEditDaemon}
/>
);
})
@@ -794,55 +881,97 @@ export default function SettingsScreen() {
{/* Appearance */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Appearance</Text>
<View style={styles.themeToggleContainer}>
<Pressable
style={[
styles.themeToggleButton,
settings.theme === "light" && styles.themeToggleButtonActive,
]}
onPress={() => handleThemeChange("light")}
>
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
Light
</Text>
</Pressable>
<Pressable
style={[
styles.themeToggleButton,
settings.theme === "dark" && styles.themeToggleButtonActive,
]}
onPress={() => handleThemeChange("dark")}
>
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
Dark
</Text>
</Pressable>
<Pressable
style={[
styles.themeToggleButton,
settings.theme === "auto" && styles.themeToggleButtonActive,
]}
onPress={() => handleThemeChange("auto")}
>
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
System
</Text>
</Pressable>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Theme</Text>
</View>
<SegmentedControl
size="sm"
value={settings.theme}
onValueChange={handleThemeChange}
options={[
{
value: "light",
label: "Light",
icon: ({ color, size }) => <Sun size={size} color={color} />,
},
{
value: "dark",
label: "Dark",
icon: ({ color, size }) => <Moon size={size} color={color} />,
},
{
value: "auto",
label: "System",
icon: ({ color, size }) => <Monitor size={size} color={color} />,
},
]}
/>
</View>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.footerAppInfo}>
<Text style={styles.footerText}>Paseo</Text>
<Text style={styles.footerVersion}>Version {appVersion}</Text>
{showDesktopPermissionSection ? (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
Desktop permissions
</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
(isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null) &&
styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshDesktopPermissions();
}}
disabled={
isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null
}
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={desktopPermissionSnapshot?.notifications ?? null}
isRequesting={requestingDesktopPermission === "notifications"}
onRequest={() => {
void handleRequestDesktopPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={desktopPermissionSnapshot?.microphone ?? null}
isRequesting={requestingDesktopPermission === "microphone"}
onRequest={() => {
void handleRequestDesktopPermission("microphone");
}}
/>
</View>
</View>
) : null}
{/* About */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Version</Text>
</View>
<Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
</View>
</View>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to defaults</Text>
</Pressable>
</View>
</View>
</ScrollView>
@@ -885,8 +1014,6 @@ function HostDetailModal({
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState("");
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
const activeServerIdRef = useRef<string | null>(null);
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
@@ -910,6 +1037,7 @@ function HostDetailModal({
// Restart logic (moved from DaemonCard)
const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null);
const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null);
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
const isConnected = daemonConnection?.isConnected ?? false;
const isConnectedRef = useRef(isConnected);
const [isRestarting, setIsRestarting] = useState(false);
@@ -975,29 +1103,21 @@ function HostDetailModal({
return;
}
if (Platform.OS === "web") {
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
: true;
if (confirmed) {
beginServerRestart();
void confirmDialog({
title: `Restart ${host.label}`,
message: restartConfirmationMessage,
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
}).then((confirmed) => {
if (!confirmed) {
return;
}
return;
}
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
{ text: "Cancel", style: "cancel" },
{
text: "Restart",
style: "destructive",
onPress: beginServerRestart,
},
]);
beginServerRestart();
}).catch((error) => {
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
});
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
// Status display
@@ -1022,39 +1142,30 @@ function HostDetailModal({
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const handleDraftLabelChange = useCallback((nextValue: string) => {
setDraftLabel(nextValue);
setIsDraftLabelDirty(true);
}, []);
useEffect(() => {
if (!visible || !host) return;
const hostChanged = activeServerIdRef.current !== host.serverId;
if (hostChanged) {
setDraftLabel(host.label ?? "");
setIsDraftLabelDirty(false);
activeServerIdRef.current = host.serverId;
return;
}
if (!isDraftLabelDirty) {
setDraftLabel(host.label ?? "");
}
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
// Initialize once per modal open / host switch; keep user edits fully local while typing.
setDraftLabel(host.label ?? "");
}, [visible, host?.serverId]);
useEffect(() => {
if (!visible) {
activeServerIdRef.current = null;
setIsRestarting(false);
setIsDraftLabelDirty(false);
setDraftLabel("");
}
}, [visible]);
@@ -1080,6 +1191,13 @@ function HostDetailModal({
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? (
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
@@ -1095,7 +1213,7 @@ function HostDetailModal({
value={draftLabel}
onChangeText={handleDraftLabelChange}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
placeholderTextColor={theme.colors.foregroundMuted}
/>
</View>
@@ -1143,13 +1261,13 @@ function HostDetailModal({
pressed && { opacity: 0.85 },
]}
>
<Settings size={14} color={theme.colors.foregroundMuted} />
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedTriggerText}>Advanced</Text>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" width={220}>
<DropdownMenuItem
onSelect={handleRestartPress}
leading={<RotateCw size={16} color={theme.colors.foregroundMuted} />}
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
@@ -1158,7 +1276,7 @@ function HostDetailModal({
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => { if (host) onRemoveHost(host); }}
leading={<Trash2 size={16} color={theme.colors.destructive} />}
leading={<Trash2 size={theme.iconSize.md} color={theme.colors.destructive} />}
>
Remove host
</DropdownMenuItem>
@@ -1238,6 +1356,60 @@ function HostDetailModal({
);
}
interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
}
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>
);
}
function ConnectionRow({
connection,
latencyMs,
@@ -1304,7 +1476,7 @@ interface DaemonCardProps {
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onPress: (daemon: HostProfile) => void;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({
@@ -1312,9 +1484,15 @@ function DaemonCard({
connectionStatus,
activeConnection,
lastError,
onPress,
onOpenSettings,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
[daemon.serverId]
)
);
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor =
@@ -1338,21 +1516,19 @@ function DaemonCard({
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
onPress={() => onPress(daemon)}
<View
style={styles.hostCard}
testID={`daemon-card-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
>
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
@@ -1375,10 +1551,35 @@ function DaemonCard({
) : null}
</View>
) : null}
{versionBadgeText ? (
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
<Pressable
style={({ pressed, hovered }) => [
styles.hostSettingsButton,
(pressed || hovered) && styles.hostSettingsButtonActive,
]}
onPress={() => onOpenSettings(daemon)}
testID={`daemon-card-settings-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`Open settings for ${daemon.label}`}
>
{({ pressed, hovered }) => (
<Settings
size={theme.iconSize.md}
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
</View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View>
</Pressable>
</View>
);
}

View File

@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { UserMessageImageAttachment } from "@/types/stream";
type PendingCreateAttempt = {
serverId: string;
@@ -6,6 +7,7 @@ type PendingCreateAttempt = {
messageId: string;
text: string;
timestamp: number;
images?: UserMessageImageAttachment[];
};
type CreateFlowState = {
@@ -24,4 +26,3 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
),
clear: () => set({ pending: null }),
}));

View File

@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url";
interface DownloadProgress {
percent: number;
@@ -303,7 +304,7 @@ function buildDownloadUrl(
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener");
void openExternalUrl(url);
}
return;
}

View File

@@ -0,0 +1,34 @@
export type ExplorerTab = "changes" | "files" | "terminals";
export function isExplorerTab(value: unknown): value is ExplorerTab {
return value === "changes" || value === "files" || value === "terminals";
}
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
const trimmedServerId = serverId.trim();
const trimmedCwd = cwd.trim();
if (!trimmedServerId || !trimmedCwd) {
return null;
}
return `${trimmedServerId}::${trimmedCwd}`;
}
export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab {
if (!isGit && tab === "changes") {
return "files";
}
return tab;
}
export function resolveExplorerTabForCheckout(params: {
serverId: string;
cwd: string;
isGit: boolean;
explorerTabByCheckout: Record<string, ExplorerTab>;
}): ExplorerTab {
const key = buildExplorerCheckoutKey(params.serverId, params.cwd);
const stored = key ? params.explorerTabByCheckout[key] : null;
const defaultTab: ExplorerTab = params.isGit ? "changes" : "files";
const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab;
return coerceExplorerTabForCheckout(nextTab, params.isGit);
}

View File

@@ -1,47 +0,0 @@
import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState {
commandCenterOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
/** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
}
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
focusChatInputRequest: null,
requestFocusChatInput: (agentKey) => {
const prev = get().focusChatInputRequest;
const id = (prev?.id ?? 0) + 1;
set({ focusChatInputRequest: { id, agentKey } });
},
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
}));

View File

@@ -0,0 +1,62 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type MessageInputActionRequest = {
id: number;
agentKey: string;
kind: MessageInputKeyboardActionKind;
};
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
shortcutsDialogOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null;
setCommandCenterOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
agentKey: string;
kind: MessageInputKeyboardActionKind;
}) => void;
clearMessageInputActionRequest: (id: number) => void;
}
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
(set, get) => ({
commandCenterOpen: false,
shortcutsDialogOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
messageInputActionRequest: null,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest;
const id = (previous?.id ?? 0) + 1;
set({ messageInputActionRequest: { id, agentKey, kind } });
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
if (!current || current.id !== id) {
return;
}
set({ messageInputActionRequest: null });
},
})
);

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
buildExplorerCheckoutKey,
resolveExplorerTabForCheckout,
} from "@/stores/explorer-tab-memory";
describe("panel-store explorer tab resolution", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
it("defaults to changes for git checkouts", () => {
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {},
})
).toBe("changes");
});
it("defaults to files for non-git checkouts", () => {
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: false,
explorerTabByCheckout: {},
})
).toBe("files");
});
it("restores a stored files tab for git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {
[key]: "files",
},
})
).toBe("files");
});
it("restores a stored terminals tab for git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {
[key]: "terminals",
},
})
).toBe("terminals");
});
it("coerces stored changes to files for non-git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: false,
explorerTabByCheckout: {
[key]: "changes",
},
})
).toBe("files");
});
});

View File

@@ -2,6 +2,14 @@ import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import {
buildExplorerCheckoutKey,
coerceExplorerTabForCheckout,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "./explorer-tab-memory";
export type { ExplorerTab } from "./explorer-tab-memory";
/**
* Mobile panel state machine.
@@ -27,8 +35,12 @@ interface DesktopSidebarState {
fileExplorerOpen: boolean;
}
export type ExplorerTab = "changes" | "files" | "terminals";
export type SortOption = "name" | "modified" | "size";
export interface ExplorerCheckoutContext {
serverId: string;
cwd: string;
isGit: boolean;
}
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
@@ -39,6 +51,23 @@ export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logPanelTransition(
action: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
const stack =
new Error().stack
?.split("\n")
.slice(2, 8)
.join("\n") ?? "stack unavailable";
console.log(`[PanelStore] ${action}`, details, stack);
}
interface PanelState {
// Mobile: which panel is currently shown
mobileView: MobilePanelView;
@@ -48,6 +77,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
explorerWidth: number;
explorerSortOption: SortOption;
explorerFilesSplitRatio: number;
@@ -55,12 +86,16 @@ interface PanelState {
// Actions
openAgentList: () => void;
openFileExplorer: () => void;
closeFileExplorer: () => void;
closeToAgent: () => void;
toggleAgentList: () => void;
toggleFileExplorer: () => void;
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
@@ -81,6 +116,18 @@ function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
if (!state.activeExplorerCheckout) {
return null;
}
return resolveExplorerTabForCheckout({
serverId: state.activeExplorerCheckout.serverId,
cwd: state.activeExplorerCheckout.cwd,
isGit: state.activeExplorerCheckout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
export const usePanelStore = create<PanelState>()(
@@ -97,62 +144,187 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
activeExplorerCheckout: null,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
openAgentList: () =>
set((state) => ({
mobileView: "agent-list",
desktop: { ...state.desktop, agentListOpen: true },
})),
set((state) => {
const nextState = {
mobileView: "agent-list" as const,
desktop: { ...state.desktop, agentListOpen: true },
};
logPanelTransition("openAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}),
openFileExplorer: () =>
set((state) => ({
mobileView: "file-explorer",
desktop: { ...state.desktop, fileExplorerOpen: true },
})),
set((state) => {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
const nextMobileView: MobilePanelView = "file-explorer";
const nextDesktop = { ...state.desktop, fileExplorerOpen: true };
const nextState = {
mobileView: nextMobileView,
desktop: nextDesktop,
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
};
logPanelTransition("openFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState;
}),
closeFileExplorer: () =>
set((state) => {
const nextState = {
mobileView:
state.mobileView === "file-explorer" ? ("agent" as const) : state.mobileView,
desktop: {
...state.desktop,
fileExplorerOpen: false,
},
};
logPanelTransition("closeFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
closeToAgent: () =>
set((state) => ({
mobileView: "agent",
// On desktop, closing depends on which panel triggered it
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
desktop: {
agentListOpen:
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
fileExplorerOpen:
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
},
})),
set((state) => {
const nextState = {
mobileView: "agent" as const,
// On desktop, closing depends on which panel triggered it
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
desktop: {
agentListOpen:
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
fileExplorerOpen:
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
},
};
logPanelTransition("closeToAgent", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
toggleAgentList: () =>
set((state) => {
// Mobile: toggle between agent and agent-list
const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list";
return {
const newMobileView: MobilePanelView =
state.mobileView === "agent-list" ? "agent" : "agent-list";
const nextState = {
mobileView: newMobileView,
desktop: {
...state.desktop,
agentListOpen: !state.desktop.agentListOpen,
},
};
logPanelTransition("toggleAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}),
toggleFileExplorer: () =>
set((state) => {
// Mobile: toggle between agent and file-explorer
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
return {
mobileView: newMobileView,
desktop: {
...state.desktop,
fileExplorerOpen: !state.desktop.fileExplorerOpen,
},
const willOpenMobile = state.mobileView !== "file-explorer";
const willOpenDesktop = !state.desktop.fileExplorerOpen;
const nextMobileView: MobilePanelView = willOpenMobile
? "file-explorer"
: "agent";
const nextDesktop = {
...state.desktop,
fileExplorerOpen: willOpenDesktop,
};
const nextState: Pick<PanelState, "mobileView" | "desktop"> &
Partial<Pick<PanelState, "explorerTab">> = {
mobileView: nextMobileView,
desktop: nextDesktop,
};
let resolvedTab: ExplorerTab | null = null;
if (willOpenMobile || willOpenDesktop) {
resolvedTab = resolveExplorerTabFromActiveCheckout(state);
if (resolvedTab) {
nextState.explorerTab = resolvedTab;
}
}
logPanelTransition("toggleFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
willOpenMobile,
willOpenDesktop,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState;
}),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
set((state) => {
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
const key = buildExplorerCheckoutKey(serverId, cwd);
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
if (key) {
const current = state.explorerTabByCheckout[key];
if (current !== resolvedTab) {
nextState.explorerTabByCheckout = {
...state.explorerTabByCheckout,
[key]: resolvedTab,
};
}
}
return nextState;
}),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
explorerTab: resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
}),
})),
setActiveExplorerCheckout: (checkout) =>
set((state) => {
const current = state.activeExplorerCheckout;
if (
current?.serverId === checkout?.serverId &&
current?.cwd === checkout?.cwd &&
current?.isGit === checkout?.isGit
) {
return state;
}
return { activeExplorerCheckout: checkout };
}),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) =>
@@ -164,7 +336,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 3,
version: 4,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -197,12 +369,29 @@ export const usePanelStore = create<PanelState>()(
}
}
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
state.explorerTabByCheckout = {};
} else {
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
state.activeExplorerCheckout = null;
return state as PanelState;
},
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
@@ -234,10 +423,14 @@ export function usePanelState(isMobile: boolean) {
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
@@ -262,10 +455,14 @@ export function usePanelState(isMobile: boolean) {
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,

View File

@@ -156,6 +156,7 @@ export interface DaemonConnectionSnapshot {
export type DaemonServerInfo = {
serverId: string;
hostname: string | null;
version: string | null;
capabilities?: ServerCapabilities;
};
@@ -455,12 +456,15 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null;
const prevHostname = session.serverInfo?.hostname?.trim() || null;
const nextVersion = info.version?.trim() || null;
const prevVersion = session.serverInfo?.version?.trim() || null;
const nextCapabilities = info.capabilities;
const prevCapabilities = session.serverInfo?.capabilities;
if (
session.serverInfo?.serverId === info.serverId &&
prevHostname === nextHostname &&
prevVersion === nextVersion &&
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
) {
return prev;
@@ -469,6 +473,7 @@ export const useSessionStore = create<SessionStore>()(
logSessionStoreUpdate("updateSessionServerInfo", serverId, {
serverId: info.serverId,
hostname: nextHostname,
version: nextVersion,
});
return {
@@ -480,6 +485,7 @@ export const useSessionStore = create<SessionStore>()(
serverInfo: {
serverId: info.serverId,
hostname: nextHostname,
version: nextVersion,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
},
},

View File

@@ -215,6 +215,13 @@ const commonTheme = {
"4xl": 34,
},
iconSize: {
xs: 12,
sm: 14,
md: 16,
lg: 20,
},
fontWeight: {
normal: "normal" as const,
medium: "500" as const,

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