Compare commits

..

77 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
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
196 changed files with 19044 additions and 9412 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

@@ -139,6 +139,7 @@ npm run android:production
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
That workflow does:
- Build iOS with the `production` profile
@@ -182,7 +183,7 @@ npm run release:patch
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
```
Notes:
@@ -196,6 +197,7 @@ Notes:
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

54
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.7",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.7",
"version": "0.1.14",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -14714,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",
@@ -14779,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": [
@@ -14825,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"
@@ -20344,7 +20362,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.7",
"version": "0.1.14",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -20352,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.7",
"@getpaseo/server": "0.1.14",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -20456,11 +20474,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.7",
"version": "0.1.14",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.7",
"@getpaseo/server": "0.1.7",
"@getpaseo/relay": "0.1.14",
"@getpaseo/server": "0.1.14",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -20510,14 +20528,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.7",
"version": "0.1.14",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.7",
"version": "0.1.14",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -20533,12 +20551,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.7",
"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.7",
"@getpaseo/relay": "0.1.14",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20558,7 +20576,7 @@
"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",
@@ -20802,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",
@@ -20890,7 +20900,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.7",
"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.7",
"version": "0.1.14",
"private": true,
"workspaces": [
"packages/server",
@@ -30,7 +30,7 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",

View File

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

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.7",
"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.7",
"@getpaseo/server": "0.1.14",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.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

@@ -42,14 +42,27 @@ 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();
@@ -57,6 +70,15 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
@@ -201,6 +223,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
@@ -217,6 +243,13 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
@@ -235,6 +268,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
animateToOpen,
animateToClose,
openAgentList,
mobileView,
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,

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>
@@ -885,6 +885,9 @@ export function WorkingDirectoryDropdown({
onSelect={onSelectPath}
searchPlaceholder="Search directories..."
emptyText={emptyText}
allowCustomValue
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isOpen}
@@ -1117,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

@@ -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,12 +99,16 @@ 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 setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -184,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(
@@ -314,6 +326,7 @@ export function AgentStreamView({
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -646,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}
@@ -666,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ import {
FlatList,
Image as RNImage,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView as RNScrollView,
Text,
@@ -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,7 +12,7 @@ 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 {
Archive,
@@ -50,8 +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
@@ -469,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 });
@@ -517,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>>({});
@@ -623,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 => {
@@ -796,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(() => {
@@ -821,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 }) => {
@@ -874,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();
@@ -887,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 =
@@ -956,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.
@@ -969,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)
@@ -1063,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,
});
@@ -1095,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
@@ -1107,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";
@@ -1127,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,
@@ -1331,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>
);
}
@@ -1531,6 +1615,7 @@ const styles = StyleSheet.create((theme) => ({
diffContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import {
View,
Text,
Image,
Pressable,
ActivityIndicator,
type LayoutChangeEvent,
@@ -61,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,
@@ -77,6 +78,7 @@ 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>
);
@@ -1121,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)}`
@@ -1240,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 }],
}));
@@ -1476,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}

View File

@@ -4,6 +4,7 @@ import {
Pressable,
Image,
Platform,
ActivityIndicator,
} from "react-native";
import { useQueries } from "@tanstack/react-query";
import {
@@ -16,7 +17,11 @@ import {
type MutableRefObject,
} from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
StyleSheet,
UnistylesRuntime,
useUnistyles,
} from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { Archive, Check, ChevronDown } from "lucide-react-native";
import {
@@ -45,6 +50,7 @@ import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping";
import { formatTimeAgo } from "@/utils/time";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
type EntryData = SidebarAgentListEntry;
@@ -110,7 +116,7 @@ function ProjectFilterOptionRow({
{option.activeCount > 0 ? (
<Text style={styles.filterOptionCount}>{option.activeCount}</Text>
) : null}
{selected ? <Check size={14} color={theme.colors.foregroundMuted} /> : null}
{selected ? <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> : null}
</View>
</Pressable>
);
@@ -123,10 +129,11 @@ interface SidebarAgentRowProps {
isSelected: boolean;
isInSelectionMode: boolean;
isBatchSelected: boolean;
isArchiving: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: () => void;
onArchive: () => Promise<void>;
onToggleBatch: () => void;
}
@@ -149,6 +156,7 @@ function SidebarAgentRow({
isSelected,
isInSelectionMode,
isBatchSelected,
isArchiving,
shortcutNumber,
onPress,
onLongPress,
@@ -172,7 +180,7 @@ function SidebarAgentRow({
const showArchive =
!isInSelectionMode &&
shortcutNumber === null &&
(isHovered || isArchiveHovered || isArchiveConfirmVisible);
(isHovered || isArchiveHovered || isArchiveConfirmVisible || isArchiving);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
@@ -186,6 +194,12 @@ function SidebarAgentRow({
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
useEffect(() => {
if (!isArchiving) {
setIsArchiveConfirmVisible(false);
}
}, [isArchiving]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
@@ -233,7 +247,7 @@ function SidebarAgentRow({
]}
>
{isBatchSelected ? (
<Check size={12} color={theme.colors.primaryForeground} />
<Check size={theme.iconSize.xs} color={theme.colors.primaryForeground} />
) : null}
</Pressable>
) : (
@@ -266,26 +280,31 @@ function SidebarAgentRow({
}}
onPress={(event) => {
event.stopPropagation();
if (isArchiving) {
return;
}
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive();
setIsArchiveConfirmVisible(false);
void onArchive();
}}
style={styles.archiveButton}
disabled={isArchiving}
testID={
isArchiveConfirmVisible
isArchiveConfirmVisible || isArchiving
? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}`
: `agent-archive-${entry.agent.serverId}-${entry.agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
<Check size={12} color={theme.colors.foreground} />
isArchiving ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : isArchiveConfirmVisible ? (
<Check size={theme.iconSize.xs} color={theme.colors.foreground} />
) : (
<Archive
size={12}
size={theme.iconSize.xs}
color={
archiveHovered
? theme.colors.foreground
@@ -434,11 +453,15 @@ export function SidebarAgentList({
}: SidebarAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set());
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
@@ -614,15 +637,17 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set([key]));
}, []);
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => {
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null;
if (!client) {
return;
}
void client.archiveAgent(entry.agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
}, []);
const handleArchiveSingle = useCallback(
async (entry: SidebarAgentListEntry): Promise<void> => {
await archiveAgent({
serverId: entry.agent.serverId,
agentId: entry.agent.id,
}).catch((error) => {
console.warn("[archive_agent] failed", error);
});
},
[archiveAgent]
);
const handleArchiveBatch = useCallback(() => {
if (selectedBatchKeys.size === 0) {
@@ -631,23 +656,18 @@ export function SidebarAgentList({
}
const requests: Promise<void>[] = [];
const store = useSessionStore.getState();
for (const key of selectedBatchKeys) {
const parsed = parseSidebarAgentKey(key);
if (!parsed) {
continue;
}
const client = store.sessions[parsed.serverId]?.client ?? null;
if (!client) {
continue;
}
requests.push(
client
.archiveAgent(parsed.agentId)
.then(() => undefined)
.catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
archiveAgent({
serverId: parsed.serverId,
agentId: parsed.agentId,
}).catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
);
}
@@ -655,7 +675,7 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set());
setIsSelectionMode(false);
});
}, [selectedBatchKeys]);
}, [archiveAgent, selectedBatchKeys]);
const handleSelectionBack = useCallback(() => {
setSelectedBatchKeys(new Set());
@@ -680,6 +700,10 @@ export function SidebarAgentList({
isSelected={selectedAgentId === key}
isInSelectionMode={isSelectionMode}
isBatchSelected={selectedBatchKeys.has(key)}
isArchiving={isArchivingAgent({
serverId: item.agent.serverId,
agentId: item.agent.id,
})}
shortcutNumber={
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
}
@@ -704,6 +728,7 @@ export function SidebarAgentList({
handleAgentLongPress,
handleAgentPress,
handleArchiveSingle,
isArchivingAgent,
isSelectionMode,
selectedAgentId,
selectedBatchKeys,
@@ -760,7 +785,7 @@ export function SidebarAgentList({
{selectedProjectLabel}
</Text>
<ChevronDown
size={14}
size={theme.iconSize.sm}
color={
showActiveForeground
? theme.colors.foreground
@@ -820,10 +845,12 @@ export function SidebarAgentList({
styles.listContent,
isSelectionMode ? styles.listContentSelectionMode : null,
]}
testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={() => {}}
showsVerticalScrollIndicator={false}
enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent}
refreshing={isRefreshing}
onRefresh={onRefresh}
@@ -889,13 +916,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
selectedProjectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
projectCountBadge: {
minWidth: 16,
height: 16,
minWidth: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -960,13 +987,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
projectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -1012,8 +1039,8 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 20,
},
checkbox: {
width: 16,
height: 16,
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -1073,8 +1100,8 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
agentMetaProjectIcon: {
width: 14,
height: 14,
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
agentMetaProjectIconInactive: {

View File

@@ -325,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}>
@@ -355,7 +356,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
@@ -417,7 +421,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Users
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -434,7 +438,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Settings
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -478,7 +482,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
@@ -538,7 +545,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
>
{({ hovered }) => (
<Users
size={20}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -554,7 +561,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
onPress={handleSettingsDesktop}
>
{({ hovered }) => (
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect } from "@react-navigation/native";
import {
ActivityIndicator,
Pressable,
@@ -23,6 +24,7 @@ import {
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
TerminalOutputPump,
type TerminalOutputChunk,
@@ -45,6 +47,7 @@ interface TerminalPaneProps {
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -159,6 +162,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
null
@@ -253,6 +257,30 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1);
}, []);
useFocusEffect(
useCallback(() => {
if (!selectedTerminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to an agent.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [requestTerminalReflow, selectedTerminalId])
);
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
@@ -561,13 +589,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [activeStreamId, flushPendingTerminalInput]);
const handleCloseTerminal = useCallback(
(terminalId: string) => {
async (terminalId: string) => {
if (
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
) {
return;
}
const confirmed = await confirmDialog({
title: "Close terminal?",
message: "Any running process in this terminal will be stopped immediately.",
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
killTerminalMutation.mutate(terminalId);
},
[killTerminalMutation]
@@ -855,7 +896,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
onPress={(event) => {
event.stopPropagation();
handleCloseTerminal(terminal.id);
void handleCloseTerminal(terminal.id);
}}
style={({ hovered, pressed }) => [
styles.terminalTabCloseButton,
@@ -938,6 +979,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
resizeRequestToken={resizeRequestToken}
/>
</View>
) : (

View File

@@ -393,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
}

File diff suppressed because it is too large Load Diff

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)) {
@@ -614,6 +555,14 @@ export function SessionProvider({
return next;
});
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
// Update agentLastActivity slice (top-level)
setAgentLastActivity(agent.id, agent.lastActivityAt);
@@ -657,6 +606,7 @@ export function SessionProvider({
previousAgentStatusRef.current.set(agent.id, agent.status);
},
[
queryClient,
serverId,
setAgents,
setAgentLastActivity,
@@ -850,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;
}
@@ -866,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(() => {
@@ -919,6 +920,7 @@ export function SessionProvider({
agentId,
reason: event.reason,
timestamp: event.timestamp,
notification: event.notification,
});
}
}
@@ -994,6 +996,7 @@ export function SessionProvider({
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities }
: {}),
@@ -1287,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)) {
@@ -1368,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);
@@ -1400,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

@@ -86,13 +86,15 @@ export function useAllAgentsList(options?: {
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,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

@@ -2,6 +2,7 @@ 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";
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return;
if (Platform.OS !== "web" || !getIsTauriMac()) return;
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
return;
}

View File

@@ -1,6 +1,7 @@
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;
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
function filePathToImageAttachment(path: string): ImageAttachment {
const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
const convertFileSrc = getTauri()?.core?.convertFileSrc;
const uri =
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
@@ -128,13 +125,28 @@ export function useFileDropZone({
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
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);
}
}
async function setupTauriDragDrop(): Promise<boolean> {
if (!isTauriEnvironment()) {
if (getTauri() === null) {
return false;
}
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
return false;
}
@@ -171,7 +183,7 @@ export function useFileDropZone({
);
if (disposed) {
unlisten();
runCleanup(unlisten);
return true;
}
@@ -271,7 +283,7 @@ export function useFileDropZone({
return () => {
disposed = true;
cleanup?.();
runCleanup();
};
}, [disabled]);

View File

@@ -18,7 +18,6 @@ import {
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
@@ -26,29 +25,14 @@ 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";
function resolveSelectedOrRouteAgentKey(input: {
selectedAgentId?: string;
pathname: string;
}): string | null {
const DRAFT_AGENT_ID = "__new_agent__";
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 useKeyboardShortcuts({
enabled,
isMobile,
@@ -161,7 +145,16 @@ export function useKeyboardShortcuts({
toggleAgentList();
return true;
case "sidebar.toggle.right":
if (!selectedAgentId || !toggleFileExplorer) {
if (!toggleFileExplorer) {
return false;
}
if (
!canToggleFileExplorerShortcut({
selectedAgentId,
pathname,
toggleFileExplorer,
})
) {
return false;
}
toggleFileExplorer();
@@ -234,7 +227,11 @@ export function useKeyboardShortcuts({
isTauri,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer),
hasSelectedAgent: canToggleFileExplorerShortcut({
selectedAgentId,
pathname,
toggleFileExplorer,
}),
},
});
if (!match) {

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

@@ -17,11 +17,8 @@ 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,
@@ -41,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";
@@ -66,14 +62,17 @@ import {
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,
@@ -88,6 +87,14 @@ 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,
@@ -220,6 +227,7 @@ function AgentScreenContent({
const router = useRouter();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -321,31 +329,27 @@ function AgentScreenContent({
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
const openExplorerForActiveCheckout = useCallback(() => {
const checkoutContext = resolveCurrentExplorerCheckout();
logAgentExplorer("openExplorerForActiveCheckout", {
hasCheckoutContext: Boolean(checkoutContext),
checkoutContext,
});
if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext);
}
openFileExplorer();
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
const handleToggleExplorer = useCallback(() => {
logAgentExplorer("handleToggleExplorer", {
isExplorerOpen,
mobileView,
isMobile,
});
if (isExplorerOpen) {
toggleFileExplorer();
return;
}
openExplorerForActiveCheckout();
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
const handleOpenExplorerFromGesture = useCallback(() => {
openExplorerForActiveCheckout();
animateExplorerToOpen();
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
}, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]);
useEffect(() => {
if (Platform.OS !== "web") {
@@ -357,53 +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) {
runOnJS(handleOpenExplorerFromGesture)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToClose,
handleOpenExplorerFromGesture,
isExplorerGesturing,
]
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent",
onOpen: openExplorerForActiveCheckout,
});
// Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => {
@@ -493,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
);
@@ -603,6 +565,10 @@ function AgentScreenContent({
}, [showConnectedNotice]);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
const isArchivingCurrentAgent = Boolean(
resolvedAgentId &&
isArchivingAgent({ serverId, agentId: resolvedAgentId })
);
useEffect(() => {
if (!resolvedAgentId) {
@@ -641,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]);
@@ -722,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();
}
}, [
@@ -729,6 +724,9 @@ function AgentScreenContent({
clearPendingCreate,
isPendingCreateForRoute,
pendingCreate,
resolvedAgentId,
serverId,
setAgentStreamTail,
streamItems,
]);
@@ -907,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");
@@ -951,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
@@ -972,7 +973,7 @@ function AgentScreenContent({
{isMobile ? (
checkout?.isGit ? (
<GitBranch
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -981,7 +982,7 @@ function AgentScreenContent({
/>
) : (
<Folder
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -991,7 +992,7 @@ function AgentScreenContent({
)
) : (
<PanelRight
size={16}
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
@@ -1008,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}>
@@ -1127,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 ? (
@@ -1159,7 +1163,7 @@ function AgentScreenContent({
>
{showConnectedNotice ? (
<CheckCircle2
size={14}
size={theme.iconSize.sm}
color={theme.colors.palette.green[600]}
/>
) : null}
@@ -1213,7 +1217,10 @@ function AgentScreenContent({
</View>
{/* Agent Input Area */}
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
{agent &&
resolvedAgentId &&
!shouldBlockForHistorySync &&
!isArchivingCurrentAgent && (
<AgentInputArea
agentId={resolvedAgentId}
serverId={serverId}
@@ -1234,6 +1241,16 @@ function AgentScreenContent({
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>
);
@@ -1395,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,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.6,
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
@@ -83,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.4,
marginBottom: theme.spacing[2],
},
input: {
@@ -178,6 +185,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
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,
@@ -300,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: {
@@ -348,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,
@@ -410,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,
@@ -435,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
@@ -533,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;
@@ -589,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.";
@@ -803,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>
@@ -917,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);
@@ -982,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
@@ -1029,13 +1142,14 @@ 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) => {
@@ -1077,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 }}>
@@ -1092,7 +1213,7 @@ function HostDetailModal({
value={draftLabel}
onChangeText={handleDraftLabelChange}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
placeholderTextColor={theme.colors.foregroundMuted}
/>
</View>
@@ -1140,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}
@@ -1155,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>
@@ -1235,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,
@@ -1312,6 +1487,12 @@ function DaemonCard({
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 =
@@ -1335,13 +1516,14 @@ 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 (
<View
@@ -1369,6 +1551,13 @@ function DaemonCard({
) : null}
</View>
) : null}
{versionBadgeText ? (
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
<Pressable
style={({ pressed, hovered }) => [
@@ -1382,7 +1571,7 @@ function DaemonCard({
>
{({ pressed, hovered }) => (
<Settings
size={16}
size={theme.iconSize.md}
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}

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

@@ -51,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;
@@ -69,6 +86,7 @@ interface PanelState {
// Actions
openAgentList: () => void;
openFileExplorer: () => void;
closeFileExplorer: () => void;
closeToAgent: () => void;
toggleAgentList: () => void;
toggleFileExplorer: () => void;
@@ -133,45 +151,102 @@ export const usePanelStore = create<PanelState>()(
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) => {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
return {
mobileView: "file-explorer",
desktop: { ...state.desktop, fileExplorerOpen: true },
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: () =>
@@ -179,19 +254,35 @@ export const usePanelStore = create<PanelState>()(
// Mobile: toggle between agent and file-explorer
const willOpenMobile = state.mobileView !== "file-explorer";
const willOpenDesktop = !state.desktop.fileExplorerOpen;
const nextState: Partial<PanelState> = {
mobileView: willOpenMobile ? "file-explorer" : "agent",
desktop: {
...state.desktop,
fileExplorerOpen: willOpenDesktop,
},
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) {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
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;
}),

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,

View File

@@ -157,4 +157,18 @@ describe("terminal-emulator-runtime", () => {
expect(onCommittedA).toHaveBeenCalledTimes(1);
expect(onCommittedB).toHaveBeenCalledTimes(1);
});
it("forces a refit when resize is requested", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
fitAndEmitResize;
runtime.resize();
runtime.resize({ force: true });
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false);
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true);
});
});

View File

@@ -76,6 +76,7 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
private outputOperations: TerminalOutputOperation[] = [];
@@ -168,6 +169,7 @@ export class TerminalEmulatorRuntime {
cols: nextCols,
});
};
this.fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize(true);
@@ -383,6 +385,10 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
resize(input?: { force?: boolean }): void {
this.fitAndEmitResize?.(input?.force ?? false);
}
focus(): void {
this.terminal?.focus();
}
@@ -413,6 +419,7 @@ export class TerminalEmulatorRuntime {
}
this.terminal = null;
this.fitAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
}

View File

@@ -3,6 +3,7 @@ import { describe, it } from "vitest";
import {
hydrateStreamState,
reduceStreamUpdate,
type AgentToolCallItem,
type StreamItem,
isAgentToolCallItem,
@@ -399,4 +400,41 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(todos);
assert.strictEqual(todos.items[0]?.text, "Task 1");
});
it("preserves optimistic user message images when authoritative user message arrives", () => {
const messageId = "msg-user-images";
const optimisticImages = [
{ uri: "file:///tmp/optimistic.jpg", mimeType: "image/jpeg" },
];
const initialState: StreamItem[] = [
{
kind: "user_message",
id: messageId,
text: "Analyze this image",
timestamp: new Date("2025-01-01T11:10:00Z"),
images: optimisticImages,
},
];
const event: AgentStreamEventPayload = {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "Analyze this image",
messageId,
},
};
const authoritativeTimestamp = new Date("2025-01-01T11:10:01Z");
const state = reduceStreamUpdate(initialState, event, authoritativeTimestamp);
const message = state.find((item) => item.kind === "user_message");
assert.ok(message);
assert.strictEqual(message.id, messageId);
assert.deepStrictEqual(message.images, optimisticImages);
assert.strictEqual(
message.timestamp.getTime(),
authoritativeTimestamp.getTime()
);
});
});

View File

@@ -59,11 +59,17 @@ export type StreamItem =
| ActivityLogItem
| CompactionItem;
export interface UserMessageImageAttachment {
uri: string;
mimeType: string;
}
export interface UserMessageItem {
kind: "user_message";
id: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
}
export interface AssistantMessageItem {
@@ -198,12 +204,20 @@ function appendUserMessage(
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId
);
const existing =
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
? state[existingIndex]
: null;
const preservedImages = existing?.images;
const nextItem: UserMessageItem = {
kind: "user_message",
id: entryId,
text: chunk,
timestamp,
...(preservedImages && preservedImages.length > 0
? { images: preservedImages }
: {}),
};
if (existingIndex >= 0) {

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type MockPlatform = "web" | "ios" | "android";
type AlertButton = {
onPress?: () => void;
};
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
alertMock: ReturnType<typeof vi.fn>;
}> {
vi.resetModules();
const alertMock = vi.fn();
vi.doMock("react-native", () => ({
Alert: {
alert: alertMock,
},
Platform: { OS: platform },
}));
const module = await import("./confirm-dialog");
return { confirmDialog: module.confirmDialog, alertMock };
}
function clearDialogGlobals(): void {
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
delete (globalThis as { confirm?: unknown }).confirm;
}
describe("confirmDialog", () => {
afterEach(() => {
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
clearDialogGlobals();
});
it("uses Tauri dialog.ask on web when available", async () => {
const askMock = vi.fn(async () => true);
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
dialog: { ask: askMock },
};
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
});
expect(confirmed).toBe(true);
expect(alertMock).not.toHaveBeenCalled();
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
title: "Restart host",
okLabel: "Restart",
cancelLabel: "Cancel",
kind: "warning",
});
});
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
const browserConfirm = vi.fn(() => true);
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
const { confirmDialog } = await loadModuleForPlatform("web");
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
});
expect(confirmed).toBe(true);
expect(browserConfirm).toHaveBeenCalledWith(
"Restart host\n\nThis will restart the daemon."
);
});
it("throws on web when no confirm backend exists", async () => {
const { confirmDialog } = await loadModuleForPlatform("web");
await expect(
confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
})
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
});
it("uses native Alert on iOS/Android", async () => {
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
alertMock.mockImplementation(
(
_title: string,
_message: string,
buttons?: AlertButton[]
) => {
const confirmButton = buttons?.[1];
confirmButton?.onPress?.();
}
);
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
});
expect(confirmed).toBe(true);
expect(alertMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,123 @@
import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
export interface ConfirmDialogInput {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
}
interface ConfirmButtonConfig {
confirmLabel: string;
cancelLabel: string;
}
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
return {
confirmLabel: input.confirmLabel ?? "Confirm",
cancelLabel: input.cancelLabel ?? "Cancel",
};
}
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
const labels = resolveButtonLabels(input);
return new Promise<boolean>((resolve) => {
Alert.alert(
input.title,
input.message,
[
{
text: labels.cancelLabel,
style: "cancel",
onPress: () => resolve(false),
},
{
text: labels.confirmLabel,
style: input.destructive ? "destructive" : "default",
onPress: () => resolve(true),
},
],
{
cancelable: true,
onDismiss: () => resolve(false),
}
);
});
}
function getTauriApi() {
if (Platform.OS !== "web") {
return null;
}
return getTauri();
}
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
const labels = resolveButtonLabels(input);
return {
title: input.title,
okLabel: labels.confirmLabel,
cancelLabel: labels.cancelLabel,
kind: input.destructive ? "warning" : "info",
};
}
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi();
if (!tauriApi) {
return null;
}
const options = buildTauriAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask;
if (typeof tauriAsk === "function") {
try {
return Boolean(await tauriAsk(input.message, options));
} catch (error) {
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
}
}
const tauriInvoke = tauriApi.core?.invoke;
if (typeof tauriInvoke === "function") {
try {
const result = await tauriInvoke("plugin:dialog|ask", {
message: input.message,
...options,
});
return result === true;
} catch (error) {
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
}
}
return null;
}
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
if (typeof browserConfirm !== "function") {
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
}
const promptMessage = `${input.title}\n\n${input.message}`;
return browserConfirm(promptMessage);
}
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
if (Platform.OS !== "web") {
return showNativeConfirmDialog(input);
}
const tauriResult = await showTauriConfirmDialog(input);
if (tauriResult !== null) {
return tauriResult;
}
return showWebConfirmDialog(input);
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
describe('findActiveFileMention', () => {
it('detects mentions at the start of input', () => {
const mention = findActiveFileMention({
text: '@src/components',
cursorIndex: '@src/components'.length,
})
expect(mention).toEqual({
start: 0,
end: '@src/components'.length,
query: 'src/components',
})
})
it('detects mentions in the middle of input using cursor position', () => {
const text = 'read "@src/com" before merging'
const cursorIndex = text.indexOf('"') + 9
const mention = findActiveFileMention({
text,
cursorIndex,
})
expect(mention).toEqual({
start: text.indexOf('@'),
end: cursorIndex,
query: 'src/com',
})
})
it('returns null when cursor is outside the mention token', () => {
const text = 'please review @src/components now'
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
})
expect(mention).toBeNull()
})
})
describe('applyFileMentionReplacement', () => {
it('replaces only the active @query segment with a quoted relative path', () => {
const text = 'open @src/com next'
const next = applyFileMentionReplacement({
text,
mention: { start: 5, end: 13, query: 'src/com' },
relativePath: 'src/components/chat.tsx',
})
expect(next).toBe('open "src/components/chat.tsx" next')
})
it('escapes double quotes in replacement path', () => {
const text = '@foo'
const next = applyFileMentionReplacement({
text,
mention: { start: 0, end: 4, query: 'foo' },
relativePath: 'src/"quoted".ts',
})
expect(next).toBe('"src/\\"quoted\\".ts"')
})
})

View File

@@ -0,0 +1,48 @@
export interface FileMentionRange {
start: number
end: number
query: string
}
interface FindActiveFileMentionInput {
text: string
cursorIndex: number
}
interface ApplyFileMentionReplacementInput {
text: string
mention: FileMentionRange
relativePath: string
}
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
const beforeCursor = input.text.slice(0, clampedCursor)
for (
let atIndex = beforeCursor.lastIndexOf('@');
atIndex >= 0;
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
) {
const query = beforeCursor.slice(atIndex + 1)
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue
}
return {
start: atIndex,
end: clampedCursor,
query,
}
}
return null
}
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
const safePath = input.relativePath.replace(/"/g, '\\"')
const before = input.text.slice(0, input.mention.start)
const after = input.text.slice(input.mention.end)
return `${before}"${safePath}"${after}`
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
collectImageFilesFromClipboardData,
filesToImageAttachments,
} from "./image-attachments-from-files";
function createClipboardItem(params: {
kind: string;
type: string;
file?: File | null;
}) {
return {
kind: params.kind,
type: params.type,
getAsFile: () => params.file ?? null,
};
}
describe("collectImageFilesFromClipboardData", () => {
it("returns only image files from clipboard items", () => {
const imagePng = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
type: "image/png",
});
const textFile = new File(["not image"], "notes.txt", {
type: "text/plain",
});
const files = collectImageFilesFromClipboardData({
items: [
createClipboardItem({ kind: "string", type: "text/plain" }),
createClipboardItem({
kind: "file",
type: "text/plain",
file: textFile,
}),
createClipboardItem({
kind: "file",
type: "image/png",
file: imagePng,
}),
createClipboardItem({
kind: "file",
type: "image/jpeg",
file: null,
}),
],
});
expect(files).toEqual([imagePng]);
});
it("returns an empty array when clipboard data is missing", () => {
expect(collectImageFilesFromClipboardData(undefined)).toEqual([]);
});
});
describe("filesToImageAttachments", () => {
it("converts files into data URI image attachments and keeps order", async () => {
const pngFile = new File([new Uint8Array([0, 1, 2, 3])], "first.png", {
type: "image/png",
});
const typeLessFile = new File([new Uint8Array([4, 5, 6, 7])], "second", {
type: "",
});
const attachments = await filesToImageAttachments([pngFile, typeLessFile]);
expect(attachments).toEqual([
{
uri: "data:image/png;base64,AAECAw==",
mimeType: "image/png",
},
{
uri: "data:image/jpeg;base64,BAUGBw==",
mimeType: "image/jpeg",
},
]);
});
});

View File

@@ -0,0 +1,67 @@
export interface ClipboardItemLike {
kind?: string;
type?: string;
getAsFile?: () => File | null;
}
export interface ClipboardDataLike {
items?: ArrayLike<ClipboardItemLike> | null;
}
export interface ImageAttachmentFromFile {
uri: string;
mimeType: string;
}
function toBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
export function collectImageFilesFromClipboardData(
clipboardData?: ClipboardDataLike | null
): File[] {
if (!clipboardData?.items) {
return [];
}
const files: File[] = [];
for (const item of Array.from(clipboardData.items)) {
if (item?.kind !== "file") {
continue;
}
if (!item.type?.startsWith("image/")) {
continue;
}
const file = item.getAsFile?.();
if (!file) {
continue;
}
files.push(file);
}
return files;
}
export async function filesToImageAttachments(
files: readonly File[]
): Promise<ImageAttachmentFromFile[]> {
return Promise.all(
files.map(async (file) => {
const mimeType = file.type || "image/jpeg";
const base64 = toBase64(await file.arrayBuffer());
return {
uri: `data:${mimeType};base64,${base64}`,
mimeType,
};
})
);
}

View File

@@ -26,6 +26,18 @@ describe("resolveNewAgentWorkingDir", () => {
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
});
it("falls back to repo root when checkout metadata is unavailable", () => {
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe(
"/repo"
);
});
it("supports windows-style paseo worktree paths without checkout metadata", () => {
expect(
resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)
).toBe("C:\\Users\\me\\repo");
});
it("returns the main repo root for paseo-owned worktrees", () => {
const checkout = {
isPaseoOwnedWorktree: true,

View File

@@ -32,11 +32,34 @@ export function resolveSelectedAgentForNewAgent(input: {
);
}
function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
const normalizedPath = cwd.replace(/\\/g, "/");
const marker = "/.paseo/worktrees";
const markerIndex = normalizedPath.indexOf(marker);
if (markerIndex <= 0) {
return null;
}
const markerEnd = markerIndex + marker.length;
const nextChar = normalizedPath[markerEnd];
if (nextChar && nextChar !== "/") {
return null;
}
const inferred = cwd.slice(0, markerIndex).replace(/[\\/]+$/, "");
return inferred.trim() ? inferred : null;
}
export function resolveNewAgentWorkingDir(
cwd: string,
checkout: CheckoutStatusPayload | null
): string {
return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd;
const explicitMainRepoRoot = checkout?.isPaseoOwnedWorktree
? checkout.mainRepoRoot?.trim() || null
: null;
if (explicitMainRepoRoot) {
return explicitMainRepoRoot;
}
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
}
export function buildNewAgentRoute(

View File

@@ -1,23 +1,13 @@
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getIsTauri } from "@/constants/layout";
interface TauriWindowWithOpener {
__TAURI__?: {
opener?: {
openUrl?: (url: string) => Promise<void>;
};
};
}
import { getTauri } from "@/utils/tauri";
export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && getIsTauri()) {
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
return;
}
const opener = getTauri()?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");

View File

@@ -18,6 +18,7 @@ type GlobalSnapshot = {
dispatchEvent: unknown;
focus: unknown;
location: unknown;
__TAURI__: unknown;
};
const originalGlobals: GlobalSnapshot = {
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
};
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
}
describe("sendOsNotification", () => {
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
});
it("uses Tauri notification module when available", async () => {
const isPermissionGranted = vi.fn(async () => false);
const requestPermission = vi.fn(async () => "granted");
const sendNotification = vi.fn(async () => undefined);
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: {
isPermissionGranted,
requestPermission,
sendNotification,
},
};
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
body: "Done",
data: { serverId: "srv-1", agentId: "agent-1" },
});
expect(sent).toBe(true);
expect(isPermissionGranted).toHaveBeenCalledTimes(1);
expect(requestPermission).toHaveBeenCalledTimes(1);
expect(sendNotification).toHaveBeenCalledWith({
title: "Agent finished",
body: "Done",
});
});
});

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
type OsNotificationPayload = {
title: string;
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
return null;
}
return getTauri()?.notification ?? null;
}
function getWebNotificationConstructor(): {
permission: string;
requestPermission?: () => Promise<string>;
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
return result;
}
export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") {
return false;
}
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
return await ensureTauriNotificationPermission(tauriNotification);
}
return await ensureNotificationPermission();
}
async function ensureTauriNotificationPermission(
notificationModule: TauriNotificationApi
): Promise<boolean> {
if (typeof notificationModule.isPermissionGranted === "function") {
try {
const granted = await notificationModule.isPermissionGranted();
if (granted) {
console.log("[OSNotifications][Tauri] Permission already granted");
return true;
}
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to check notification permission",
error
);
}
}
if (typeof notificationModule.requestPermission !== "function") {
console.warn(
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
);
return false;
}
try {
const result = await notificationModule.requestPermission();
console.log("[OSNotifications][Tauri] requestPermission result:", result);
return result === "granted";
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to request notification permission",
error
);
return false;
}
}
async function sendTauriNotification(
payload: OsNotificationPayload,
notificationModule: TauriNotificationApi
): Promise<boolean> {
if (typeof notificationModule.sendNotification !== "function") {
console.warn(
"[OSNotifications][Tauri] notification.sendNotification is unavailable"
);
return false;
}
const granted = await ensureTauriNotificationPermission(notificationModule);
if (!granted) {
console.log("[OSNotifications][Tauri] Permission not granted");
return false;
}
try {
await notificationModule.sendNotification({
title: payload.title,
body: payload.body,
});
return true;
} catch (error) {
console.warn("[OSNotifications][Tauri] Failed to send notification", error);
return false;
}
}
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
@@ -127,12 +215,28 @@ export async function sendOsNotification(
return false;
}
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
console.log("[OSNotifications] Using Tauri notification module");
return await sendTauriNotification(payload, tauriNotification);
}
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
console.log(
"[OSNotifications][Web] Notification constructor unavailable",
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown-origin"
);
return false;
}
const granted = await ensureNotificationPermission();
const granted = await ensureOsNotificationPermission();
if (!granted) {
console.log(
"[OSNotifications][Web] Permission not granted:",
NotificationConstructor.permission
);
return false;
}
const notification = new NotificationConstructor(payload.title, {

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { mergePendingCreateImages } from "./pending-create-images";
function userMessage(params: {
id: string;
text: string;
images?: Array<{ uri: string; mimeType: string }>;
}): StreamItem {
return {
kind: "user_message",
id: params.id,
text: params.text,
timestamp: new Date("2026-01-01T00:00:00Z"),
...(params.images ? { images: params.images } : {}),
};
}
describe("mergePendingCreateImages", () => {
it("returns same reference when pending images are absent", () => {
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
const result = mergePendingCreateImages({
streamItems,
messageId: "msg-1",
text: "hello",
images: [],
});
expect(result).toBe(streamItems);
});
it("merges images by messageId when the matched message has none", () => {
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
const images = [{ uri: "file:///tmp/image-1.jpg", mimeType: "image/jpeg" }];
const result = mergePendingCreateImages({
streamItems,
messageId: "msg-1",
text: "hello",
images,
});
expect(result).not.toBe(streamItems);
const updated = result[0];
expect(updated?.kind).toBe("user_message");
if (updated?.kind !== "user_message") {
throw new Error("Expected user_message item");
}
expect(updated.images).toEqual(images);
});
it("falls back to text matching when messageId does not match", () => {
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
const images = [{ uri: "file:///tmp/image-2.jpg", mimeType: "image/jpeg" }];
const result = mergePendingCreateImages({
streamItems,
messageId: "missing-id",
text: "same text",
images,
});
const updated = result[0];
expect(updated?.kind).toBe("user_message");
if (updated?.kind !== "user_message") {
throw new Error("Expected user_message item");
}
expect(updated.images).toEqual(images);
});
it("does not overwrite existing user message images", () => {
const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }];
const streamItems = [
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
];
const result = mergePendingCreateImages({
streamItems,
messageId: "msg-1",
text: "hello",
images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }],
});
expect(result).toBe(streamItems);
const unchanged = result[0];
expect(unchanged?.kind).toBe("user_message");
if (unchanged?.kind !== "user_message") {
throw new Error("Expected user_message item");
}
expect(unchanged.images).toEqual(existingImages);
});
});

View File

@@ -0,0 +1,39 @@
import type { StreamItem, UserMessageImageAttachment } from "@/types/stream";
type MergePendingCreateImagesParams = {
streamItems: StreamItem[];
messageId: string;
text: string;
images?: UserMessageImageAttachment[];
};
export function mergePendingCreateImages({
streamItems,
messageId,
text,
images,
}: MergePendingCreateImagesParams): StreamItem[] {
if (!images || images.length === 0) {
return streamItems;
}
const targetIndex = streamItems.findIndex(
(item) =>
item.kind === "user_message" && (item.id === messageId || item.text === text)
);
if (targetIndex < 0) {
return streamItems;
}
const target = streamItems[targetIndex];
if (target.kind !== "user_message") {
return streamItems;
}
if (target.images && target.images.length > 0) {
return streamItems;
}
const next = [...streamItems];
next[targetIndex] = { ...target, images };
return next;
}

View File

@@ -11,6 +11,7 @@ function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
return {
serverId: "srv-1",
hostname: "test-host",
version: "0.1.0",
...(capabilities ? { capabilities } : {}),
};
}

View File

@@ -1,4 +1,5 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import { getTauri } from "@/utils/tauri";
type TauriWebSocketMessage =
| { type: "Text"; data: string }
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
return Array.from(data);
}
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getTauriWebSocketModule(): TauriWebSocketModule | null {
if (!isTauriEnvironment()) {
return null;
}
const ws = (window as any).__TAURI__?.websocket;
const ws = getTauri()?.websocket;
if (ws && typeof ws.connect === "function") {
return ws as TauriWebSocketModule;
}
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
}
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
if (!isTauriEnvironment()) {
if (getTauri() === null) {
return null;
}
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
}
if (!module) {
throw new Error(
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
);
}

View File

@@ -1,15 +1,10 @@
import { Platform } from "react-native";
import { useState, useEffect } from "react";
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
let tauriWindow: any = null;
// Runtime check for Tauri environment
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" &&
(window as any).__TAURI__ !== undefined;
}
async function getTauriWindow() {
if (tauriWindow) return tauriWindow;
@@ -19,15 +14,18 @@ async function getTauriWindow() {
}
try {
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`.
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
// the native (Hermes) builds and break parsing/runtime on mobile.
const tauri = (window as any).__TAURI__ as any;
const tauri = getTauri();
if (!tauri) {
return null;
}
// Prefer the public global window module.
const windowModule = tauri?.window;
if (windowModule && typeof windowModule.getCurrentWindow === "function") {
tauriWindow = windowModule.getCurrentWindow();
const publicWindow = getCurrentTauriWindow();
if (publicWindow) {
tauriWindow = publicWindow;
return tauriWindow;
}
@@ -109,7 +107,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
useEffect(() => {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
let unlisten: (() => void) | undefined;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[TauriWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[TauriWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = await getTauriWindow();
@@ -117,19 +129,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
// Check initial fullscreen state
const fullscreen = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fullscreen);
// Listen for resize events which include fullscreen changes
unlisten = await win.onResized(async () => {
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
setup();
void setup();
return () => {
unlisten?.();
disposed = true;
runCleanup();
};
}, []);

View File

@@ -0,0 +1,92 @@
export type TauriNotificationPermission = "granted" | "denied" | "default";
export interface TauriDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
}
export interface TauriCoreApi {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
convertFileSrc?: (path: string) => string;
}
export interface TauriEventApi {
listen?: (
event: string,
handler: (event: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowApi {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowModule {
getCurrentWindow?: () => TauriWindowApi;
}
export interface TauriOpenerApi {
openUrl?: (url: string) => Promise<void>;
}
export interface TauriNotificationApi {
isPermissionGranted?: () => Promise<boolean>;
requestPermission?: () => Promise<TauriNotificationPermission>;
sendNotification?: (
payload: string | { title: string; body?: string }
) => Promise<void>;
}
export interface TauriWebSocketApi {
connect?: (url: string, config?: unknown) => Promise<unknown>;
}
export interface TauriApi {
core?: TauriCoreApi;
dialog?: TauriDialogApi;
event?: TauriEventApi;
notification?: TauriNotificationApi;
opener?: TauriOpenerApi;
websocket?: TauriWebSocketApi;
window?: TauriWindowModule;
}
export function getTauri(): TauriApi | null {
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
if (!tauri || typeof tauri !== "object") {
return null;
}
return tauri as TauriApi;
}
export function isTauriEnvironment(): boolean {
return getTauri() !== null;
}
export function getCurrentTauriWindow(): TauriWindowApi | null {
const getter = getTauri()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.7",
"version": "0.1.14",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -22,8 +22,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.7",
"@getpaseo/server": "0.1.7",
"@getpaseo/relay": "0.1.14",
"@getpaseo/server": "0.1.14",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

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