mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810ac63e21 | ||
|
|
0f66aa9d67 | ||
|
|
4df517d84b | ||
|
|
e1d51dfec9 | ||
|
|
7d1122a371 | ||
|
|
1473820cc4 | ||
|
|
ff0be38cff | ||
|
|
7bda40bd4e | ||
|
|
fb6b5ca886 | ||
|
|
8d379a6215 | ||
|
|
d4a9ee482a | ||
|
|
2f98f073ce | ||
|
|
719ae73c7d | ||
|
|
dd5218ebdb | ||
|
|
f1f4afaaa9 | ||
|
|
b6b0933a39 | ||
|
|
24a8db5763 | ||
|
|
08d4cfac11 | ||
|
|
02bcfcd5af | ||
|
|
a1fdeeb7ae | ||
|
|
bcae95c372 | ||
|
|
62d69410d3 | ||
|
|
e8c4ae2024 | ||
|
|
e22f183bb2 | ||
|
|
cbdf8ea0bc | ||
|
|
e1bd459bfd | ||
|
|
f070e86746 | ||
|
|
e26dcde2c8 | ||
|
|
005875de2b | ||
|
|
dd4cc82ce8 | ||
|
|
52044e2a7d | ||
|
|
95eec2b6c0 | ||
|
|
f2fa7bb79e | ||
|
|
b1afb2673d | ||
|
|
32a0e7e8ef | ||
|
|
b09cb77514 | ||
|
|
8e02a59a93 | ||
|
|
4affac9ce9 | ||
|
|
229e747244 | ||
|
|
69e009990c | ||
|
|
5079ca386b | ||
|
|
d30bb84741 | ||
|
|
e36d318001 | ||
|
|
e88a0c1db6 | ||
|
|
b1955cf74d | ||
|
|
4305a37ec3 | ||
|
|
96a80036f6 | ||
|
|
dab08865c7 | ||
|
|
483966a05b | ||
|
|
08a35c9dbf | ||
|
|
d3704456a3 | ||
|
|
62e3b214ff | ||
|
|
64b36d0df7 | ||
|
|
803e31e61c | ||
|
|
79b64d3e38 | ||
|
|
0337204281 | ||
|
|
2324065e0a | ||
|
|
d51fd74f55 | ||
|
|
792f5d8d6e | ||
|
|
63b088a646 | ||
|
|
fdb3c6830d | ||
|
|
ab8ae975f6 | ||
|
|
ecd276e3d2 | ||
|
|
0354c409f1 | ||
|
|
6aa5e97d75 | ||
|
|
8c0d289c2a | ||
|
|
a5842482b0 | ||
|
|
9b9535aa78 | ||
|
|
515cb0a777 | ||
|
|
0dc944df3a | ||
|
|
16ea92aec5 | ||
|
|
93ec537095 | ||
|
|
9255212043 | ||
|
|
a03da6774a | ||
|
|
7c5bbc8048 | ||
|
|
76e6ad89d2 | ||
|
|
93d6eb611e | ||
|
|
ce5d9d6dc8 | ||
|
|
abc146b186 | ||
|
|
4300750001 | ||
|
|
0af2d8989a | ||
|
|
34e35621f0 | ||
|
|
efde557d6f | ||
|
|
50b99584a1 | ||
|
|
459af22670 | ||
|
|
12ba65d6e7 | ||
|
|
bb6d30e4f8 | ||
|
|
82a5723240 | ||
|
|
949ac8decb | ||
|
|
de99646880 | ||
|
|
170ee27813 | ||
|
|
22da0951d5 | ||
|
|
c371dc92a5 | ||
|
|
427e3daca9 | ||
|
|
f590e60617 | ||
|
|
259209b7ef | ||
|
|
93111b47ae | ||
|
|
f8f4c55976 | ||
|
|
90a81462cb | ||
|
|
7008803800 | ||
|
|
52b3001111 | ||
|
|
6c7ce63272 | ||
|
|
04a06197de | ||
|
|
5bd6cc11de | ||
|
|
4313e3623b |
119
.github/workflows/android-apk-release.yml
vendored
Normal file
119
.github/workflows/android-apk-release.yml
vendored
Normal 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 }}"
|
||||
7
.github/workflows/deploy-app.yml
vendored
7
.github/workflows/deploy-app.yml
vendored
@@ -2,11 +2,8 @@ name: Deploy App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/app/**'
|
||||
- 'packages/server/src/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/desktop-release.yml
vendored
2
.github/workflows/desktop-release.yml
vendored
@@ -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
|
||||
|
||||
67
.github/workflows/release-notes-sync.yml
vendored
Normal file
67
.github/workflows/release-notes-sync.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Release Notes Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry."
|
||||
required: false
|
||||
type: string
|
||||
create_if_missing:
|
||||
description: "Create release if missing (normally only needed for tag events)."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync release body from changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
args=(--repo "$REPO")
|
||||
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
args+=(--tag "$INPUT_TAG")
|
||||
fi
|
||||
|
||||
create_if_missing="false"
|
||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
||||
create_if_missing="true"
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
create_if_missing="true"
|
||||
fi
|
||||
|
||||
if [ "$create_if_missing" = "true" ]; then
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
146
CHANGELOG.md
Normal file
146
CHANGELOG.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.15 - 2026-02-19
|
||||
### Improved
|
||||
- Improved website release visibility with a public changelog page plus cleaner GitHub navigation and changelog heading presentation.
|
||||
- Improved website onboarding by redesigning the homepage get-started flow into a clearer two-step experience.
|
||||
- Improved app draft/new-agent UX by simplifying working directory placeholder and empty-state messaging and enabling drag interactions in previously unhandled draft areas on Tauri.
|
||||
- Improved sidebar structure by renaming `Sidebar` to `LeftSidebar` and hiding empty filter groups.
|
||||
|
||||
### Fixed
|
||||
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
|
||||
- Fixed duplicate `/rewind` user-message behavior and added an end-to-end regression test.
|
||||
|
||||
### CI / Docs
|
||||
- Updated release automation to sync GitHub release notes from `CHANGELOG.md`.
|
||||
- Updated app deployment workflow to run only on release tags.
|
||||
- Added an explicit changelog maintenance step to the release checklist.
|
||||
|
||||
## 0.1.14 - 2026-02-19
|
||||
### Added
|
||||
- Added Claude `/rewind` command support.
|
||||
- Added slash command access in the draft agent composer.
|
||||
- Added `@` workspace file autocomplete in chat prompts.
|
||||
- Added support for pasting images directly into prompt attachments.
|
||||
- Added optimistic image previews for pending user message attachments.
|
||||
- Added shared desktop/web overlay scroll handles, including file preview panes.
|
||||
|
||||
### Improved
|
||||
- Improved worktree flow after shipping, including better merged PR detection.
|
||||
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
|
||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||
- Improved desktop settings and permissions UX in Tauri.
|
||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||
|
||||
### Fixed
|
||||
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
|
||||
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
|
||||
- Fixed terminal sizing when switching back to an agent session.
|
||||
- Fixed accidental terminal closure risk by adding confirmation for running shell commands.
|
||||
- Fixed archive loading-state consistency across the sidebar and agent screen.
|
||||
- Fixed autocomplete popover stability and workspace suggestion ranking.
|
||||
- Fixed dictation timeouts caused by dangling non-final segments.
|
||||
- Fixed server lock ownership when spawned as a child process by using parent PID ownership.
|
||||
- Fixed hidden directory leakage in server CWD suggestions.
|
||||
- Fixed agent attention notification payload consistency across providers.
|
||||
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
|
||||
|
||||
## 0.1.9 - 2026-02-17
|
||||
### Improved
|
||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||
- Reused provider availability checks for structured generation fallback selection.
|
||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||
|
||||
### Fixed
|
||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||
|
||||
## 0.1.8 - 2026-02-17
|
||||
### Added
|
||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||
|
||||
### Improved
|
||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||
- Updated website hero copy to emphasize local execution.
|
||||
|
||||
### Fixed
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## 0.1.7 - 2026-02-16
|
||||
### Added
|
||||
- Improved agent workspace flows with better directory suggestions.
|
||||
- Added iOS TestFlight and Android app access request forms on the website.
|
||||
|
||||
### Improved
|
||||
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||
- Improved website app download and update guidance.
|
||||
|
||||
### Fixed
|
||||
- Prevented an initial desktop combobox `0,0` position flash.
|
||||
- Fixed CLI version output issues.
|
||||
- Hardened server runtime loading for local speech dependencies.
|
||||
|
||||
## 0.1.6 - 2026-02-16
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## 0.1.5 - 2026-02-16
|
||||
### Added
|
||||
- Added terminal reattach support and better worktree terminal handling.
|
||||
- Added global keyboard shortcut help in the app.
|
||||
- Added sidebar host filtering and improved agent workflow controls.
|
||||
|
||||
### Improved
|
||||
- Improved worktree setup visibility by streaming setup progress.
|
||||
- Improved terminal streaming reliability and lifecycle handling.
|
||||
- Preserved explorer tab state so context survives navigation better.
|
||||
|
||||
## 0.1.4 - 2026-02-14
|
||||
### Added
|
||||
- Added voice capability status reporting in the client.
|
||||
- Added background local speech model downloads with runtime gating.
|
||||
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||
|
||||
### Improved
|
||||
- Improved connection selection and agent hydration reliability.
|
||||
- Improved timeline loading with cursor-based fetch behavior.
|
||||
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||
- Improved inline code rendering by auto-linkifying URLs.
|
||||
|
||||
### Fixed
|
||||
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||
- Fixed stale relay client timer behavior.
|
||||
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||
|
||||
## 0.1.3 - 2026-02-12
|
||||
### Added
|
||||
- Added CLI onboarding command.
|
||||
- Added CLI `--output-schema` support for structured agent output.
|
||||
- Added CLI agent metadata update support for names and labels.
|
||||
- Added provider availability detection with normalization of legacy default model IDs.
|
||||
|
||||
### Improved
|
||||
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||
- Added better voice interrupt handling with a speech-start grace period.
|
||||
- Improved CLI defaults to list all non-archived agents by default.
|
||||
- Improved website UX with clearer install CTA and privacy policy access.
|
||||
|
||||
### Fixed
|
||||
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||
|
||||
## 0.1.2 - 2026-02-11
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## 0.1.1 - 2026-02-11
|
||||
|
||||
### Added
|
||||
- Initial `0.1.x` release line.
|
||||
@@ -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:
|
||||
@@ -191,11 +192,13 @@ Notes:
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed
|
||||
- `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
|
||||
|
||||
97
README.md
97
README.md
@@ -17,91 +17,42 @@
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
|
||||
## Features
|
||||
|
||||
- **Self-hosted:** The daemon runs on your laptop, home server, or VPS
|
||||
- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface
|
||||
- **Multi-host:** Connect to multiple daemons and see all your agents in one place
|
||||
- **Voice input:** Dictate prompts when you're away from your keyboard
|
||||
- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI
|
||||
- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app
|
||||
- **Open source:** Free and open source under MIT license
|
||||
|
||||
## Quick Start
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli && paseo
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
|
||||
## Local speech (STT/TTS)
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`.
|
||||
## Development
|
||||
|
||||
When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`).
|
||||
Quick monorepo package map:
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
npm run speech:download --workspace=@getpaseo/server
|
||||
```
|
||||
# run all local dev services
|
||||
npm run dev
|
||||
|
||||
Optional configuration:
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:website
|
||||
|
||||
- `PASEO_SHERPA_ONNX_MODELS_DIR` (defaults to `~/.paseo/models/sherpa-onnx`)
|
||||
- `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD` (`1` by default; set `0` to disable automatic downloads on daemon start)
|
||||
- `PASEO_SHERPA_STT_PRESET` (`zipformer`, `paraformer`, or `parakeet` for NVIDIA Parakeet TDT v3)
|
||||
- `PASEO_SHERPA_TTS_PRESET` (`pocket-tts` (Kyutai Pocket TTS), `kitten`, or `kokoro`)
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER` (`sherpa` or `openai`)
|
||||
|
||||
To see all supported local model IDs:
|
||||
|
||||
```bash
|
||||
npm run speech:models --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
Optional: run an end-to-end test that downloads real models and exercises streaming STT + streaming TTS:
|
||||
|
||||
```bash
|
||||
PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \
|
||||
npx vitest run --workspace=@getpaseo/server src/server/speech/sherpa/speech-download.e2e.test.ts
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
|
||||
|
||||
## Releases
|
||||
|
||||
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
|
||||
|
||||
```bash
|
||||
npm run version:all:patch
|
||||
npm run release:push
|
||||
```
|
||||
|
||||
For the full package release flow, use:
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
`npm run release:patch` bumps all workspace versions together, publishes npm packages (`@getpaseo/relay`, `@getpaseo/server`, `@getpaseo/cli`), and pushes the matching `v*` tag.
|
||||
|
||||
The tag triggers:
|
||||
- GitHub `Desktop Release` workflow (`.github/workflows/desktop-release.yml`)
|
||||
- Expo EAS mobile workflow (`packages/app/.eas/workflows/release-mobile.yml`) to build + submit Android/iOS
|
||||
|
||||
Useful monitoring commands after a release push:
|
||||
|
||||
```bash
|
||||
# Desktop (GitHub Actions)
|
||||
gh run list --workflow "Desktop Release" --limit 10
|
||||
gh run watch <run-id>
|
||||
|
||||
# Mobile (EAS Workflows)
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
1236
package-lock.json
generated
1236
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -12,7 +12,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
@@ -30,7 +30,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
|
||||
function resolveSecretFile(params) {
|
||||
const fromEnv = process.env[params.envKey];
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
||||
return fromEnv.trim();
|
||||
}
|
||||
|
||||
const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath);
|
||||
if (fs.existsSync(fallbackAbsolutePath)) {
|
||||
return params.fallbackRelativePath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
production: {
|
||||
name: "Paseo",
|
||||
packageId: "sh.paseo",
|
||||
googleServicesFile: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICES_FILE_PROD",
|
||||
fallbackRelativePath: "./.secrets/google-services.prod.json",
|
||||
}),
|
||||
googleServiceInfoPlist: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD",
|
||||
fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist",
|
||||
}),
|
||||
},
|
||||
development: {
|
||||
name: "Paseo Debug",
|
||||
packageId: "sh.paseo.debug",
|
||||
googleServicesFile: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICES_FILE_DEBUG",
|
||||
fallbackRelativePath: "./.secrets/google-services.debug.json",
|
||||
}),
|
||||
googleServiceInfoPlist: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG",
|
||||
fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,6 +70,9 @@ export default {
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
bundleIdentifier: variant.packageId,
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -57,6 +92,9 @@ export default {
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
package: variant.packageId,
|
||||
...(variant.googleServicesFile
|
||||
? { googleServicesFile: variant.googleServicesFile }
|
||||
: {}),
|
||||
},
|
||||
web: {
|
||||
output: "single",
|
||||
|
||||
@@ -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.
|
||||
|
||||
42
packages/app/e2e/draft-explorer-sidebar.spec.ts
Normal file
42
packages/app/e2e/draft-explorer-sidebar.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
53
packages/app/e2e/paste-image-attachment.spec.ts
Normal file
53
packages/app/e2e/paste-image-attachment.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
130
packages/app/e2e/sidebar-project-filter-flash.spec.ts
Normal file
130
packages/app/e2e/sidebar-project-filter-flash.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== document.body) {
|
||||
const style = getComputedStyle(current);
|
||||
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (!element.textContent?.includes("No projects")) continue;
|
||||
const container = getContainer(element);
|
||||
if (!container) continue;
|
||||
target = container;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -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-");
|
||||
|
||||
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal file
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0"
|
||||
"version": ">= 14.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -18,6 +19,9 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": "versionCode"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
@@ -33,6 +37,9 @@
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6758887924"
|
||||
},
|
||||
"android": {
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.15",
|
||||
"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.6",
|
||||
"@getpaseo/server": "0.1.15",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -77,6 +77,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -42,14 +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,
|
||||
@@ -248,12 +282,12 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const content = (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<KeyboardShortcutsDialog />
|
||||
|
||||
@@ -97,7 +97,7 @@ export function DropdownField({
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -196,7 +196,7 @@ export function SelectField({
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -289,7 +289,7 @@ export function DropdownSheet({
|
||||
hitSlop={10}
|
||||
testID="dropdown-sheet-close"
|
||||
>
|
||||
<X size={18} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
@@ -477,7 +477,7 @@ export function FormSelectTrigger({
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -571,7 +571,7 @@ export function AgentConfigRow({
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -585,7 +585,7 @@ export function AgentConfigRow({
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -598,7 +598,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -612,7 +612,7 @@ export function AgentConfigRow({
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
onSelect={onSelectThinkingOption}
|
||||
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -862,16 +862,14 @@ export function WorkingDirectoryDropdown({
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
|
||||
const emptyText = suggestedPaths.length > 0
|
||||
? "No agent directories match your search."
|
||||
: "We'll suggest directories from agents on this host once they exist.";
|
||||
const emptyText = "No agent directories match your search.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label="WORKING DIRECTORY"
|
||||
value={workingDir}
|
||||
placeholder="/path/to/project"
|
||||
placeholder="Choose a working directory"
|
||||
onPress={handleOpen}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage || undefined}
|
||||
@@ -883,11 +881,12 @@ export function WorkingDirectoryDropdown({
|
||||
options={options}
|
||||
value={workingDir}
|
||||
onSelect={onSelectPath}
|
||||
searchPlaceholder="/path/to/project"
|
||||
searchPlaceholder="Search directories..."
|
||||
emptyText={emptyText}
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Launch the agent in this directory"
|
||||
customValuePrefix=""
|
||||
customValueKind="directory"
|
||||
optionsPosition="above-search"
|
||||
title="Working directory"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
@@ -1119,16 +1118,16 @@ export function GitOptionsSection({
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
|
||||
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} />
|
||||
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,12 +105,6 @@ export function AgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear attention flag when opening agent
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(agentId);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
62
packages/app/src/components/git-action-visibility.test.ts
Normal file
62
packages/app/src/components/git-action-visibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
packages/app/src/components/git-action-visibility.ts
Normal file
14
packages/app/src/components/git-action-visibility.ts
Normal 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;
|
||||
}
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Linking from "expo-linking";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
@@ -51,7 +50,13 @@ import {
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
// =============================================================================
|
||||
// Git Actions Data Structure
|
||||
@@ -85,11 +90,7 @@ interface GitActions {
|
||||
}
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
} else {
|
||||
void Linking.openURL(url);
|
||||
}
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||
@@ -473,9 +474,13 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
@@ -521,6 +526,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
@@ -627,17 +633,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
bodyHeightByPathRef.current[path] = height;
|
||||
}, []);
|
||||
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
const handleDiffListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number => {
|
||||
@@ -800,10 +818,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
@@ -825,15 +847,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
@@ -878,6 +901,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||
const baseRefLabel = useMemo(() => {
|
||||
if (!baseRef) return "base";
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
@@ -891,12 +915,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
? undefined
|
||||
: `${branchLabel} -> ${baseRefLabel}`;
|
||||
}, [baseRefLabel, branchLabel]);
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
const shouldPromoteArchive =
|
||||
isPaseoOwnedWorktree &&
|
||||
!hasUncommittedChanges &&
|
||||
(postShipArchiveSuggested || isMergedPullRequest);
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
const archiveDisabled =
|
||||
@@ -960,7 +999,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? diffScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
@@ -973,11 +1018,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
// ==========================================================================
|
||||
// Git Actions (Data-Oriented)
|
||||
@@ -1067,16 +1110,28 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update from base - only when not on base branch
|
||||
if (!isOnBaseBranch) {
|
||||
// Update/sync from base
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: `Update from ${baseRefLabel}`,
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
@@ -1099,8 +1154,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
if (hasUncommittedChanges) {
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
@@ -1111,7 +1170,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: Ahead of base → Ship action based on preference
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
@@ -1131,20 +1194,25 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: archive worktree only
|
||||
const menu = allActions.has("archive-worktree")
|
||||
? [allActions.get("archive-worktree")!]
|
||||
: [];
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
@@ -1335,7 +1403,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar && hasChanges}
|
||||
metrics={diffScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
diffListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1535,6 +1615,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SidebarMenuToggle({
|
||||
{isMobile ? (
|
||||
<MobileMenuIcon color={menuIconColor} />
|
||||
) : (
|
||||
<PanelLeft size={16} color={menuIconColor} />
|
||||
<PanelLeft size={theme.iconSize.md} color={menuIconColor} />
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
@@ -43,11 +43,11 @@ import {
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
|
||||
interface SlidingSidebarProps {
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -324,8 +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}>
|
||||
@@ -354,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>
|
||||
</>
|
||||
)}
|
||||
@@ -416,7 +421,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -433,7 +438,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -465,7 +470,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
@@ -474,7 +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>
|
||||
</>
|
||||
)}
|
||||
@@ -534,7 +545,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -550,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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type LayoutChangeEvent,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import * as Linking from "expo-linking";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "@/styles/markdown-styles";
|
||||
import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildToolCallDisplayModel,
|
||||
@@ -71,12 +71,14 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
timestamp: number;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
@@ -117,6 +119,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
}
|
||||
`;
|
||||
let webToolCallShimmerRegistered = false;
|
||||
const SCROLL_EDGE_EPSILON = 0.5;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -143,6 +147,113 @@ function ensureWebToolCallShimmerKeyframes() {
|
||||
webToolCallShimmerRegistered = true;
|
||||
}
|
||||
|
||||
function getWheelEventElementTarget(
|
||||
event: WheelEvent,
|
||||
fallback: HTMLElement
|
||||
): HTMLElement {
|
||||
const { target } = event;
|
||||
if (target instanceof HTMLElement) {
|
||||
return target;
|
||||
}
|
||||
if (target instanceof Node && target.parentElement) {
|
||||
return target.parentElement;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function canElementScrollInDirection(
|
||||
element: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||||
const isScrollableOverflow =
|
||||
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||||
if (!isScrollableOverflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||||
const scrollSize =
|
||||
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
|
||||
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
|
||||
function canScrollInsideDetailFromTarget(
|
||||
detailRoot: HTMLElement,
|
||||
startElement: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current: HTMLElement | null = startElement;
|
||||
while (current) {
|
||||
if (canElementScrollInDirection(current, axis, delta)) {
|
||||
return true;
|
||||
}
|
||||
if (current === detailRoot) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldStopDetailWheelPropagation(
|
||||
detailRoot: HTMLElement,
|
||||
event: WheelEvent
|
||||
): boolean {
|
||||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||||
const verticalDelta = event.deltaY;
|
||||
const horizontalDelta =
|
||||
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
|
||||
|
||||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||||
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canScrollVertically = hasVerticalIntent
|
||||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||||
: false;
|
||||
const canScrollHorizontally = hasHorizontalIntent
|
||||
? canScrollInsideDetailFromTarget(
|
||||
detailRoot,
|
||||
startElement,
|
||||
"x",
|
||||
horizontalDelta
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasVerticalIntent && hasHorizontalIntent) {
|
||||
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||||
return isVerticalDominant
|
||||
? canScrollVertically || canScrollHorizontally
|
||||
: canScrollHorizontally || canScrollVertically;
|
||||
}
|
||||
|
||||
if (hasVerticalIntent) {
|
||||
return canScrollVertically;
|
||||
}
|
||||
return canScrollHorizontally;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -177,6 +288,24 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
lineHeight: 22,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imagePreviewSpacing: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
imagePill: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
copyButton: {
|
||||
alignSelf: "flex-end",
|
||||
padding: theme.spacing[1],
|
||||
@@ -192,6 +321,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
message,
|
||||
images = [],
|
||||
timestamp,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
@@ -201,8 +331,10 @@ export const UserMessage = memo(function UserMessage({
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const showCopyButton =
|
||||
Platform.OS !== "web" || messageHovered || copyButtonHovered;
|
||||
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -227,21 +359,45 @@ export const UserMessage = memo(function UserMessage({
|
||||
}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
{hasImages ? (
|
||||
<View
|
||||
style={[
|
||||
userMessageStylesheet.imagePreviewContainer,
|
||||
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||||
]}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<View
|
||||
key={`${image.uri}-${index}`}
|
||||
style={userMessageStylesheet.imagePill}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: image.uri }}
|
||||
style={userMessageStylesheet.imageThumbnail}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<TurnCopyButton
|
||||
getContent={() => message}
|
||||
containerStyle={[
|
||||
userMessageStylesheet.copyButton,
|
||||
showCopyButton
|
||||
? userMessageStylesheet.copyButtonVisible
|
||||
: userMessageStylesheet.copyButtonHidden,
|
||||
]}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setCopyButtonHovered}
|
||||
/>
|
||||
{hasText ? (
|
||||
<TurnCopyButton
|
||||
getContent={() => message}
|
||||
containerStyle={[
|
||||
userMessageStylesheet.copyButton,
|
||||
showCopyButton
|
||||
? userMessageStylesheet.copyButtonVisible
|
||||
: userMessageStylesheet.copyButtonHidden,
|
||||
]}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setCopyButtonHovered}
|
||||
/>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -540,12 +696,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
void Linking.openURL(url);
|
||||
}
|
||||
return true;
|
||||
void openExternalUrl(url);
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
@@ -1123,6 +1277,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const hasDetailContent = Boolean(renderDetails);
|
||||
const detailContent =
|
||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||
const detailWrapperRef = useRef<View | null>(null);
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||
@@ -1242,6 +1397,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
shimmerTranslateX,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||
return () => {
|
||||
node.removeEventListener("wheel", stopWheelPropagation);
|
||||
};
|
||||
}, [isExpanded, hasDetailContent]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
}));
|
||||
@@ -1478,6 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
onHoverIn={() => onDetailHoverChange?.(true)}
|
||||
onHoverOut={() => onDetailHoverChange?.(false)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -465,6 +488,7 @@ export function SidebarAgentList({
|
||||
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null
|
||||
);
|
||||
}, [projectOptions, selectedProjectKeys]);
|
||||
const showProjectFilters = projectOptions.length > 0;
|
||||
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const projectIconRequests = useMemo(() => {
|
||||
@@ -592,11 +616,6 @@ export function SidebarAgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[entry.agent.serverId];
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(entry.agent.id);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
@@ -619,15 +638,17 @@ export function SidebarAgentList({
|
||||
setSelectedBatchKeys(new Set([key]));
|
||||
}, []);
|
||||
|
||||
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => {
|
||||
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.archiveAgent(entry.agent.id).catch((error) => {
|
||||
console.warn("[archive_agent] failed", error);
|
||||
});
|
||||
}, []);
|
||||
const handleArchiveSingle = useCallback(
|
||||
async (entry: SidebarAgentListEntry): Promise<void> => {
|
||||
await archiveAgent({
|
||||
serverId: entry.agent.serverId,
|
||||
agentId: entry.agent.id,
|
||||
}).catch((error) => {
|
||||
console.warn("[archive_agent] failed", error);
|
||||
});
|
||||
},
|
||||
[archiveAgent]
|
||||
);
|
||||
|
||||
const handleArchiveBatch = useCallback(() => {
|
||||
if (selectedBatchKeys.size === 0) {
|
||||
@@ -636,23 +657,18 @@ export function SidebarAgentList({
|
||||
}
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
const store = useSessionStore.getState();
|
||||
for (const key of selectedBatchKeys) {
|
||||
const parsed = parseSidebarAgentKey(key);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const client = store.sessions[parsed.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
continue;
|
||||
}
|
||||
requests.push(
|
||||
client
|
||||
.archiveAgent(parsed.agentId)
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
console.warn("[archive_agent_batch] failed", { key, error });
|
||||
})
|
||||
archiveAgent({
|
||||
serverId: parsed.serverId,
|
||||
agentId: parsed.agentId,
|
||||
}).catch((error) => {
|
||||
console.warn("[archive_agent_batch] failed", { key, error });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -660,7 +676,7 @@ export function SidebarAgentList({
|
||||
setSelectedBatchKeys(new Set());
|
||||
setIsSelectionMode(false);
|
||||
});
|
||||
}, [selectedBatchKeys]);
|
||||
}, [archiveAgent, selectedBatchKeys]);
|
||||
|
||||
const handleSelectionBack = useCallback(() => {
|
||||
setSelectedBatchKeys(new Set());
|
||||
@@ -685,6 +701,10 @@ export function SidebarAgentList({
|
||||
isSelected={selectedAgentId === key}
|
||||
isInSelectionMode={isSelectionMode}
|
||||
isBatchSelected={selectedBatchKeys.has(key)}
|
||||
isArchiving={isArchivingAgent({
|
||||
serverId: item.agent.serverId,
|
||||
agentId: item.agent.id,
|
||||
})}
|
||||
shortcutNumber={
|
||||
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
|
||||
}
|
||||
@@ -709,6 +729,7 @@ export function SidebarAgentList({
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
handleArchiveSingle,
|
||||
isArchivingAgent,
|
||||
isSelectionMode,
|
||||
selectedAgentId,
|
||||
selectedBatchKeys,
|
||||
@@ -725,98 +746,100 @@ export function SidebarAgentList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.filtersRow}>
|
||||
<Pressable
|
||||
ref={projectFilterAnchorRef}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.filterTrigger,
|
||||
(selectedProjectKeys.length > 0 || hovered || pressed) &&
|
||||
styles.filterTriggerActive,
|
||||
]}
|
||||
onPress={() => setIsProjectFilterOpen(true)}
|
||||
>
|
||||
{({ hovered = false, pressed }) => {
|
||||
const isInteracting = hovered || pressed;
|
||||
const showActiveForeground =
|
||||
selectedProjectKeys.length > 0 || isInteracting;
|
||||
return (
|
||||
<>
|
||||
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: selectedProjectIconUri,
|
||||
}}
|
||||
style={styles.selectedProjectIcon}
|
||||
{showProjectFilters ? (
|
||||
<View style={styles.filtersRow}>
|
||||
<Pressable
|
||||
ref={projectFilterAnchorRef}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.filterTrigger,
|
||||
(selectedProjectKeys.length > 0 || hovered || pressed) &&
|
||||
styles.filterTriggerActive,
|
||||
]}
|
||||
onPress={() => setIsProjectFilterOpen(true)}
|
||||
>
|
||||
{({ hovered = false, pressed }) => {
|
||||
const isInteracting = hovered || pressed;
|
||||
const showActiveForeground =
|
||||
selectedProjectKeys.length > 0 || isInteracting;
|
||||
return (
|
||||
<>
|
||||
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: selectedProjectIconUri,
|
||||
}}
|
||||
style={styles.selectedProjectIcon}
|
||||
/>
|
||||
) : selectedProjectKeys.length > 1 ? (
|
||||
<View style={styles.projectCountBadge}>
|
||||
<Text style={styles.projectCountBadgeText}>
|
||||
{selectedProjectKeys.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.filterTriggerText,
|
||||
!showActiveForeground && styles.filterTriggerTextMuted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedProjectLabel}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={theme.iconSize.sm}
|
||||
color={
|
||||
showActiveForeground
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : selectedProjectKeys.length > 1 ? (
|
||||
<View style={styles.projectCountBadge}>
|
||||
<Text style={styles.projectCountBadgeText}>
|
||||
{selectedProjectKeys.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.filterTriggerText,
|
||||
!showActiveForeground && styles.filterTriggerTextMuted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedProjectLabel}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
color={
|
||||
showActiveForeground
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
|
||||
{selectedProjectKeys.length > 0 ? (
|
||||
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
|
||||
<Text style={styles.clearFilterText}>Clear</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Combobox
|
||||
options={[]}
|
||||
value=""
|
||||
onSelect={() => {}}
|
||||
title="Filter by project"
|
||||
placeholder="Search projects"
|
||||
searchPlaceholder="Search projects"
|
||||
desktopPlacement="bottom-start"
|
||||
open={isProjectFilterOpen}
|
||||
onOpenChange={setIsProjectFilterOpen}
|
||||
anchorRef={projectFilterAnchorRef}
|
||||
>
|
||||
<View style={styles.filterOptionsList}>
|
||||
{projectOptions.length === 0 ? (
|
||||
<Text style={styles.filterEmptyText}>No projects</Text>
|
||||
) : (
|
||||
projectOptions.map((option) => (
|
||||
<ProjectFilterOptionRow
|
||||
key={option.projectKey}
|
||||
option={option}
|
||||
selected={selectedProjectKeys.includes(option.projectKey)}
|
||||
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
|
||||
displayName={deriveProjectDisplayName({
|
||||
projectKey: option.projectKey,
|
||||
projectName: option.projectName,
|
||||
remoteUrl: null,
|
||||
})}
|
||||
onToggle={handleToggleProject}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Combobox>
|
||||
</View>
|
||||
{selectedProjectKeys.length > 0 ? (
|
||||
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
|
||||
<Text style={styles.clearFilterText}>Clear</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Combobox
|
||||
options={[]}
|
||||
value=""
|
||||
onSelect={() => {}}
|
||||
title="Filter by project"
|
||||
placeholder="Search projects"
|
||||
searchPlaceholder="Search projects"
|
||||
desktopPlacement="bottom-start"
|
||||
open={isProjectFilterOpen}
|
||||
onOpenChange={setIsProjectFilterOpen}
|
||||
anchorRef={projectFilterAnchorRef}
|
||||
>
|
||||
<View style={styles.filterOptionsList}>
|
||||
{projectOptions.length === 0 ? (
|
||||
<Text style={styles.filterEmptyText}>No projects</Text>
|
||||
) : (
|
||||
projectOptions.map((option) => (
|
||||
<ProjectFilterOptionRow
|
||||
key={option.projectKey}
|
||||
option={option}
|
||||
selected={selectedProjectKeys.includes(option.projectKey)}
|
||||
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
|
||||
displayName={deriveProjectDisplayName({
|
||||
projectKey: option.projectKey,
|
||||
projectName: option.projectName,
|
||||
remoteUrl: null,
|
||||
})}
|
||||
onToggle={handleToggleProject}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Combobox>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<DraggableList
|
||||
data={entries}
|
||||
@@ -825,10 +848,12 @@ export function SidebarAgentList({
|
||||
styles.listContent,
|
||||
isSelectionMode ? styles.listContentSelectionMode : null,
|
||||
]}
|
||||
testID="sidebar-agent-list-scroll"
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderRow}
|
||||
onDragEnd={() => {}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
enableDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -894,13 +919,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
selectedProjectIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectCountBadge: {
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
minWidth: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -965,13 +990,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
projectIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1017,8 +1042,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 20,
|
||||
},
|
||||
checkbox: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1078,8 +1103,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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -15,12 +16,15 @@ import Svg, {
|
||||
Stop,
|
||||
} from "react-native-svg";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
@@ -43,6 +47,7 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -89,6 +94,8 @@ type PendingTerminalInput =
|
||||
};
|
||||
};
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -134,6 +141,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
@@ -154,6 +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
|
||||
@@ -245,8 +254,36 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return () => clearHoverOutTimeout();
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!selectedTerminalId) {
|
||||
return;
|
||||
}
|
||||
// Navigation transitions can temporarily report stale dimensions.
|
||||
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||
setTimeout(() => {
|
||||
requestTerminalReflow();
|
||||
}, delayMs)
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const handle of timeoutHandles) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [requestTerminalReflow, selectedTerminalId])
|
||||
);
|
||||
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", serverId, cwd] as const,
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -281,14 +318,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd.startsWith("/")) {
|
||||
@@ -303,10 +340,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
@@ -317,7 +354,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
unsubscribe();
|
||||
client.unsubscribeTerminals({ cwd });
|
||||
};
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
|
||||
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -327,12 +364,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return await client.createTerminal(cwd);
|
||||
},
|
||||
onSuccess: (payload) => {
|
||||
if (payload.terminal) {
|
||||
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
|
||||
setSelectedTerminalId(payload.terminal.id);
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd: current?.cwd ?? cwd,
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
});
|
||||
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
||||
setSelectedTerminalId(createdTerminal.id);
|
||||
requestTerminalFocus();
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -358,10 +409,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
},
|
||||
@@ -538,22 +589,31 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}, [activeStreamId, flushPendingTerminalInput]);
|
||||
|
||||
const handleCloseTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
async (terminalId: string) => {
|
||||
if (
|
||||
killTerminalMutation.isPending &&
|
||||
killTerminalMutation.variables === terminalId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Close terminal?",
|
||||
message: "Any running process in this terminal will be stopped immediately.",
|
||||
confirmLabel: "Close",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
killTerminalMutation.mutate(terminalId);
|
||||
},
|
||||
[killTerminalMutation]
|
||||
);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const clearPendingModifiers = useCallback(() => {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}, []);
|
||||
@@ -836,7 +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,
|
||||
@@ -919,6 +979,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
62
packages/app/src/components/ui/autocomplete-utils.test.ts
Normal file
62
packages/app/src/components/ui/autocomplete-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
packages/app/src/components/ui/autocomplete-utils.ts
Normal file
56
packages/app/src/components/ui/autocomplete-utils.ts
Normal 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;
|
||||
}
|
||||
272
packages/app/src/components/ui/autocomplete.tsx
Normal file
272
packages/app/src/components/ui/autocomplete.tsx
Normal 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>
|
||||
76
packages/app/src/components/ui/combobox-options.test.ts
Normal file
76
packages/app/src/components/ui/combobox-options.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
packages/app/src/components/ui/combobox-options.ts
Normal file
95
packages/app/src/components/ui/combobox-options.ts
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -9,63 +9,89 @@ import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
useWindowDimensions,
|
||||
} from 'react-native'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, Search } from "lucide-react-native";
|
||||
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { Check, File, Folder, Search } from 'lucide-react-native'
|
||||
import {
|
||||
flip,
|
||||
offset as floatingOffset,
|
||||
shift,
|
||||
size as floatingSize,
|
||||
useFloating,
|
||||
} from '@floating-ui/react-native'
|
||||
import { getNextActiveIndex } from './combobox-keyboard'
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
shouldShowCustomComboboxOption,
|
||||
} from './combobox-options'
|
||||
import type { ComboboxOptionModel } from './combobox-options'
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = Platform.OS === 'web'
|
||||
|
||||
export interface ComboboxOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
export type ComboboxOption = ComboboxOptionModel
|
||||
|
||||
export interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
allowCustomValue?: boolean;
|
||||
customValuePrefix?: string;
|
||||
customValueDescription?: string;
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onSelect: (id: string) => void
|
||||
onSearchQueryChange?: (query: string) => void
|
||||
searchable?: boolean
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
allowCustomValue?: boolean
|
||||
customValuePrefix?: string
|
||||
customValueDescription?: string
|
||||
customValueKind?: 'directory' | 'file'
|
||||
optionsPosition?: 'below-search' | 'above-search'
|
||||
title?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
desktopPlacement?: 'top-start' | 'bottom-start'
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||
*/
|
||||
desktopPreventInitialFlash?: boolean
|
||||
anchorRef: React.RefObject<View | null>
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function toNumericStyleValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[style, styles.bottomSheetBackground]}
|
||||
/>
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
placeholder: string
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onSubmitEditing?: () => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
@@ -75,18 +101,18 @@ function SearchInput({
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
const { theme } = useUnistyles()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoFocus]);
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
@@ -94,7 +120,7 @@ function SearchInput({
|
||||
<InputComponent
|
||||
ref={inputRef as any}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -104,27 +130,29 @@ function SearchInput({
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
label: string
|
||||
description?: string
|
||||
kind?: 'directory' | 'file'
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { theme } = useUnistyles()
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
@@ -136,10 +164,23 @@ export function ComboboxItem({
|
||||
active && styles.comboboxItemActive,
|
||||
]}
|
||||
>
|
||||
{kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? (
|
||||
@@ -148,11 +189,15 @@ export function ComboboxItem({
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
||||
return <Text style={styles.emptyText}>{children}</Text>;
|
||||
return (
|
||||
<Text testID="combobox-empty-text" style={styles.emptyText}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
@@ -161,281 +206,353 @@ export function Combobox({
|
||||
onSelect,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = "Search...",
|
||||
placeholder = 'Search...',
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
emptyText = 'No options match your search.',
|
||||
allowCustomValue = false,
|
||||
customValuePrefix = "Use",
|
||||
customValuePrefix = 'Use',
|
||||
customValueDescription,
|
||||
title = "Select",
|
||||
customValueKind,
|
||||
optionsPosition = 'below-search',
|
||||
title = 'Select',
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPlacement = 'top-start',
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null)
|
||||
const snapPoints = useMemo(() => ['60%', '90%'], [])
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||
null
|
||||
)
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null)
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1)
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null)
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
const isControlled = typeof open === 'boolean'
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const isOpen = isControlled ? open : internalOpen
|
||||
|
||||
const setOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
setInternalOpen(nextOpen)
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
)
|
||||
|
||||
const setSearchQueryWithCallback = useCallback(
|
||||
(nextQuery: string) => {
|
||||
setSearchQuery(nextQuery);
|
||||
onSearchQueryChange?.(nextQuery);
|
||||
setSearchQuery(nextQuery)
|
||||
onSearchQueryChange?.(nextQuery)
|
||||
},
|
||||
[onSearchQueryChange]
|
||||
);
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearchQueryWithCallback("");
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
setOpen(false)
|
||||
setSearchQueryWithCallback('')
|
||||
}, [setOpen, setSearchQueryWithCallback])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQueryWithCallback("");
|
||||
setSearchQueryWithCallback('')
|
||||
}
|
||||
}, [isOpen, setSearchQueryWithCallback]);
|
||||
}, [isOpen, setSearchQueryWithCallback])
|
||||
|
||||
const collisionPadding = useMemo(() => {
|
||||
const basePadding = 16;
|
||||
if (Platform.OS !== "android") return basePadding;
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
||||
return Math.max(basePadding, statusBarHeight + basePadding);
|
||||
}, []);
|
||||
const basePadding = 16
|
||||
if (Platform.OS !== 'android') return basePadding
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0
|
||||
return Math.max(basePadding, statusBarHeight + basePadding)
|
||||
}, [])
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||
shift({ padding: collisionPadding }),
|
||||
floatingOffset(Platform.OS === 'web' ? 0 : 4),
|
||||
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
apply({ availableWidth, availableHeight, rects }) {
|
||||
setAvailableSize((prev) => {
|
||||
const next = { width: availableWidth, height: availableHeight };
|
||||
if (!prev) return next;
|
||||
if (prev.width === next.width && prev.height === next.height) return prev;
|
||||
return next;
|
||||
});
|
||||
const next = { width: availableWidth, height: availableHeight }
|
||||
if (!prev) return next
|
||||
if (prev.width === next.width && prev.height === next.height) return prev
|
||||
return next
|
||||
})
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
const next = rects.reference.width
|
||||
if (prev === next) return prev
|
||||
return next
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding]
|
||||
);
|
||||
[collisionPadding, isDesktopAboveSearch]
|
||||
)
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
reference: anchorRef.current ?? undefined,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
setAvailableSize(null)
|
||||
setReferenceWidth(null)
|
||||
return
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
const raf = requestAnimationFrame(() => void update())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [desktopPlacement, isMobile, update, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
if (!isOpen || isMobile) {
|
||||
setReferenceAtOrigin(false)
|
||||
setReferenceTop(null)
|
||||
return
|
||||
}
|
||||
}, [isOpen, isMobile]);
|
||||
|
||||
const referenceEl = anchorRef.current
|
||||
if (!referenceEl) {
|
||||
setReferenceAtOrigin(false)
|
||||
setReferenceTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
referenceEl.measureInWindow((x, y) => {
|
||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
|
||||
setReferenceTop((prev) => (prev === y ? prev : y))
|
||||
})
|
||||
}
|
||||
|
||||
measure()
|
||||
const raf = requestAnimationFrame(measure)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
|
||||
|
||||
const floatingTop = toNumericStyleValue(floatingStyles.top)
|
||||
const floatingLeft = toNumericStyleValue(floatingStyles.left)
|
||||
const desktopAboveSearchBottom =
|
||||
isDesktopAboveSearch && referenceTop !== null
|
||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||
: null
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
floatingLeft !== null &&
|
||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: floatingStyles
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present()
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss()
|
||||
}
|
||||
}, [isOpen, isMobile])
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClose();
|
||||
handleClose()
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
)
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return options;
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
|
||||
const sanitizedSearchValue = searchQuery.trim()
|
||||
const showCustomOption = useMemo(
|
||||
() =>
|
||||
shouldShowCustomComboboxOption({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
allowCustomValue,
|
||||
}),
|
||||
[allowCustomValue, options, searchQuery, searchable]
|
||||
)
|
||||
|
||||
const visibleOptions = useMemo(
|
||||
() =>
|
||||
buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
allowCustomValue,
|
||||
customValuePrefix,
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
}),
|
||||
[
|
||||
allowCustomValue,
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
customValuePrefix,
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
]
|
||||
)
|
||||
|
||||
const orderedVisibleOptions = useMemo(
|
||||
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
||||
[effectiveOptionsPosition, visibleOptions]
|
||||
)
|
||||
|
||||
const pinDesktopOptionsToBottom = useCallback(() => {
|
||||
if (isMobile || effectiveOptionsPosition !== 'above-search') {
|
||||
return
|
||||
}
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.description?.toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}, [options, normalizedSearch]);
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||
requestAnimationFrame(() => {
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||
})
|
||||
}, [effectiveOptionsPosition, isMobile])
|
||||
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
searchable &&
|
||||
allowCustomValue &&
|
||||
sanitizedSearchValue.length > 0 &&
|
||||
!options.some(
|
||||
(opt) =>
|
||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const next: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
if (showCustomOption) {
|
||||
next.push({
|
||||
id: sanitizedSearchValue,
|
||||
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
|
||||
description: customValueDescription,
|
||||
});
|
||||
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const opt of filteredOptions) {
|
||||
next.push({
|
||||
id: opt.id,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}, [
|
||||
customValueDescription,
|
||||
customValuePrefix,
|
||||
filteredOptions,
|
||||
sanitizedSearchValue,
|
||||
showCustomOption,
|
||||
]);
|
||||
pinDesktopOptionsToBottom()
|
||||
}, [isOpen, pinDesktopOptionsToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!IS_WEB && isMobile) return;
|
||||
|
||||
if (visibleOptions.length === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
pinDesktopOptionsToBottom()
|
||||
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
return
|
||||
}
|
||||
void update()
|
||||
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!IS_WEB && isMobile) return
|
||||
|
||||
if (orderedVisibleOptions.length === 0) {
|
||||
setActiveIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackIndex = getComboboxFallbackIndex(
|
||||
orderedVisibleOptions.length,
|
||||
effectiveOptionsPosition
|
||||
)
|
||||
|
||||
if (normalizedSearch) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
setActiveIndex(fallbackIndex)
|
||||
return
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
|
||||
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
|
||||
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id);
|
||||
handleClose();
|
||||
onSelect(id)
|
||||
handleClose()
|
||||
},
|
||||
[handleClose, onSelect]
|
||||
);
|
||||
)
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
if (showCustomOption) {
|
||||
handleSelect(sanitizedSearchValue);
|
||||
handleSelect(sanitizedSearchValue)
|
||||
}
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption])
|
||||
|
||||
const handleDesktopKey = useCallback(
|
||||
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (!IS_WEB && isMobile) return;
|
||||
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
if (!IS_WEB && isMobile) return
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
event?.preventDefault();
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
||||
event?.preventDefault()
|
||||
setActiveIndex((currentIndex) =>
|
||||
getNextActiveIndex({
|
||||
currentIndex,
|
||||
itemCount: visibleOptions.length,
|
||||
itemCount: orderedVisibleOptions.length,
|
||||
key,
|
||||
})
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (visibleOptions.length === 0) return;
|
||||
event?.preventDefault();
|
||||
if (key === 'Enter') {
|
||||
if (orderedVisibleOptions.length === 0) return
|
||||
event?.preventDefault()
|
||||
const index =
|
||||
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
|
||||
handleSelect(visibleOptions[index]!.id);
|
||||
return;
|
||||
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
|
||||
handleSelect(orderedVisibleOptions[index]!.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === "Escape") {
|
||||
event?.preventDefault();
|
||||
handleClose();
|
||||
if (key === 'Escape') {
|
||||
event?.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
||||
);
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !isOpen) return;
|
||||
if (!IS_WEB || !isOpen) return
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
||||
return;
|
||||
const key = event.key
|
||||
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
handleDesktopKey(key, event);
|
||||
};
|
||||
handleDesktopKey(key, event)
|
||||
}
|
||||
|
||||
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler, true);
|
||||
};
|
||||
}, [handleDesktopKey, isOpen]);
|
||||
window.removeEventListener('keydown', handler, true)
|
||||
}
|
||||
}, [handleDesktopKey, isOpen])
|
||||
|
||||
const searchInput = (
|
||||
<SearchInput
|
||||
@@ -445,16 +562,17 @@ export function Combobox({
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
);
|
||||
)
|
||||
|
||||
const optionsList = (
|
||||
<>
|
||||
{visibleOptions.length > 0 ? (
|
||||
visibleOptions.map((opt, index) => (
|
||||
{orderedVisibleOptions.length > 0 ? (
|
||||
orderedVisibleOptions.map((opt, index) => (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
kind={opt.kind}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
@@ -464,14 +582,17 @@ export function Combobox({
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
const content = children ?? (
|
||||
const defaultContent = (
|
||||
<>
|
||||
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
|
||||
{searchable ? searchInput : null}
|
||||
{optionsList}
|
||||
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
const content = children ?? defaultContent
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -499,33 +620,31 @@ export function Combobox({
|
||||
{content}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return <></>;
|
||||
if (!isOpen) return <></>
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
|
||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
testID="combobox-desktop-container"
|
||||
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
||||
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||
style={[
|
||||
styles.desktopContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
minWidth: referenceWidth ?? 200,
|
||||
maxWidth: 400,
|
||||
},
|
||||
floatingStyles,
|
||||
referenceWidth === null ? { opacity: 0 } : null,
|
||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||
desktopPositionStyle,
|
||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||
typeof availableSize?.height === 'number'
|
||||
? { maxHeight: Math.min(availableSize.height, 400) }
|
||||
: null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
@@ -542,27 +661,44 @@ export function Combobox({
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{effectiveOptionsPosition === 'above-search' ? (
|
||||
<ScrollView
|
||||
ref={desktopOptionsScrollRef}
|
||||
contentContainerStyle={[
|
||||
styles.desktopScrollContent,
|
||||
styles.desktopScrollContentAboveSearch,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
onContentSizeChange={handleDesktopOptionsContentSizeChange}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{searchable ? searchInput : null}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
{effectiveOptionsPosition === 'below-search' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -580,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
comboboxItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -605,14 +741,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
comboboxItemTrailingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
comboboxItemContent: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
@@ -630,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
@@ -644,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "left",
|
||||
textAlign: 'left',
|
||||
},
|
||||
comboboxScrollContent: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
@@ -655,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
@@ -666,18 +807,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
desktopScroll: {
|
||||
maxHeight: 400,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopScrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
desktopScrollContentAboveSearch: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}))
|
||||
|
||||
145
packages/app/src/components/ui/segmented-control.tsx
Normal file
145
packages/app/src/components/ui/segmented-control.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
81
packages/app/src/components/web-desktop-scrollbar.math.ts
Normal file
81
packages/app/src/components/web-desktop-scrollbar.math.ts
Normal 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);
|
||||
}
|
||||
82
packages/app/src/components/web-desktop-scrollbar.test.ts
Normal file
82
packages/app/src/components/web-desktop-scrollbar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
451
packages/app/src/components/web-desktop-scrollbar.tsx
Normal file
451
packages/app/src/components/web-desktop-scrollbar.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
291
packages/app/src/hooks/use-agent-autocomplete.ts
Normal file
291
packages/app/src/hooks/use-agent-autocomplete.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
69
packages/app/src/hooks/use-archive-agent.test.ts
Normal file
69
packages/app/src/hooks/use-archive-agent.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
236
packages/app/src/hooks/use-archive-agent.ts
Normal file
236
packages/app/src/hooks/use-archive-agent.ts
Normal 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,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
120
packages/app/src/hooks/use-autocomplete.ts
Normal file
120
packages/app/src/hooks/use-autocomplete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -216,9 +216,6 @@ export function useCommandCenter() {
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
didNavigateRef.current = true;
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
session?.client?.clearAgentAttention(agent.id);
|
||||
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
138
packages/app/src/hooks/use-explorer-open-gesture.ts
Normal file
138
packages/app/src/hooks/use-explorer-open-gesture.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getCurrentTauriWindow } from "@/utils/tauri";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
@@ -34,6 +36,21 @@ function deriveFaviconStatus(
|
||||
return "none";
|
||||
}
|
||||
|
||||
function deriveMacDockBadgeCount(
|
||||
agents: ReturnType<typeof useAggregatedAgents>["agents"]
|
||||
): number | undefined {
|
||||
const attentionCount = agents.filter(
|
||||
(agent) =>
|
||||
agent.requiresAttention &&
|
||||
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
|
||||
).length;
|
||||
if (attentionCount > 0) {
|
||||
return attentionCount;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||
const image = FAVICON_IMAGES[colorScheme][status];
|
||||
if (typeof image === "object" && "uri" in image) {
|
||||
@@ -73,9 +90,25 @@ function getSystemColorScheme(): ColorScheme {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauriWindow.setBadgeCount(count);
|
||||
} catch (error) {
|
||||
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function useFaviconStatus() {
|
||||
const { agents } = useAggregatedAgents();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Listen for system color scheme changes
|
||||
useEffect(() => {
|
||||
@@ -96,5 +129,11 @@ export function useFaviconStatus() {
|
||||
|
||||
const status = deriveFaviconStatus(agents);
|
||||
updateFavicon(status, colorScheme);
|
||||
|
||||
const dockBadgeCount = deriveMacDockBadgeCount(agents);
|
||||
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}
|
||||
}, [agents, colorScheme]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
@@ -13,11 +14,71 @@ interface UseFileDropZoneReturn {
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
};
|
||||
|
||||
type TauriDragDropPayload =
|
||||
| {
|
||||
type: "enter";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "over";
|
||||
}
|
||||
| {
|
||||
type: "drop";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "leave";
|
||||
};
|
||||
|
||||
type TauriDragDropEvent = {
|
||||
payload: TauriDragDropPayload;
|
||||
};
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
function getFileExtension(path: string): string {
|
||||
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||
if (extensionIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
return normalizedPath.slice(extensionIndex).toLowerCase();
|
||||
}
|
||||
|
||||
function isImagePath(path: string): boolean {
|
||||
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
|
||||
}
|
||||
|
||||
function filePathToImageAttachment(path: string): ImageAttachment {
|
||||
const extension = getFileExtension(path);
|
||||
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||
const convertFileSrc = getTauri()?.core?.convertFileSrc;
|
||||
const uri =
|
||||
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
||||
|
||||
return {
|
||||
uri,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -62,78 +123,167 @@ export function useFileDropZone({
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
function runCleanup(unlisten?: () => void | Promise<void>) {
|
||||
if (didCleanup) return;
|
||||
const cleanupFn = unlisten ?? cleanup;
|
||||
if (!cleanupFn) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
async function setupTauriDragDrop(): Promise<boolean> {
|
||||
if (getTauri() === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(
|
||||
imageFiles.map(fileToImageAttachment)
|
||||
const unlisten = await tauriWindow.onDragDropEvent(
|
||||
(event: TauriDragDropEvent) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop always ends the current drag operation.
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const imagePaths = payload.paths.filter(isImagePath);
|
||||
if (imagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = imagePaths.map(filePathToImageAttachment);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
}
|
||||
);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
|
||||
if (disposed) {
|
||||
runCleanup(unlisten);
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup = unlisten;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
function setupDomDragDrop() {
|
||||
const element = containerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(
|
||||
imageFiles.map(fileToImageAttachment)
|
||||
);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
|
||||
cleanup = () => {
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const tauriListenersAttached = await setupTauriDragDrop();
|
||||
if (disposed || tauriListenersAttached) {
|
||||
return;
|
||||
}
|
||||
setupDomDragDrop();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
packages/app/src/keyboard/keyboard-shortcut-routing.test.ts
Normal file
45
packages/app/src/keyboard/keyboard-shortcut-routing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/app/src/keyboard/keyboard-shortcut-routing.ts
Normal file
40
packages/app/src/keyboard/keyboard-shortcut-routing.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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,13 +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,
|
||||
@@ -87,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,
|
||||
@@ -219,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);
|
||||
|
||||
@@ -320,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") {
|
||||
@@ -356,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(() => {
|
||||
@@ -492,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
|
||||
);
|
||||
@@ -512,6 +475,9 @@ function AgentScreenContent({
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||
kind: "idle",
|
||||
@@ -599,6 +565,27 @@ function AgentScreenContent({
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
const isArchivingCurrentAgent = Boolean(
|
||||
resolvedAgentId &&
|
||||
isArchivingAgent({ serverId, agentId: resolvedAgentId })
|
||||
);
|
||||
const hasRedirectedArchivedAgentRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
hasRedirectedArchivedAgentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!agent?.archivedAt) {
|
||||
hasRedirectedArchivedAgentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasRedirectedArchivedAgentRef.current) {
|
||||
return;
|
||||
}
|
||||
hasRedirectedArchivedAgentRef.current = true;
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
}, [agent?.archivedAt, resolvedAgentId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -637,6 +624,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]);
|
||||
@@ -718,6 +708,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();
|
||||
}
|
||||
}, [
|
||||
@@ -725,6 +741,9 @@ function AgentScreenContent({
|
||||
clearPendingCreate,
|
||||
isPendingCreateForRoute,
|
||||
pendingCreate,
|
||||
resolvedAgentId,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
streamItems,
|
||||
]);
|
||||
|
||||
@@ -860,25 +879,30 @@ function AgentScreenContent({
|
||||
document.title = title;
|
||||
}, [agent?.title]);
|
||||
|
||||
// Track previous agent status to detect completion while viewing
|
||||
const previousStatusRef = useRef<string | null>(null);
|
||||
|
||||
// Clear attention when agent finishes while user is viewing this screen
|
||||
// Clear attention as soon as the user is focused on this agent screen.
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId || !agent || !client) {
|
||||
const clearAgentId = resolvedAgentId?.trim();
|
||||
if (!clearAgentId || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = previousStatusRef.current;
|
||||
const currentStatus = agent.status;
|
||||
previousStatusRef.current = currentStatus;
|
||||
|
||||
// If agent transitioned from running to idle while we're viewing,
|
||||
// immediately clear attention since user witnessed the completion
|
||||
if (previousStatus === "running" && currentStatus === "idle") {
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
if (
|
||||
!shouldClearAgentAttentionOnView({
|
||||
agentId: clearAgentId,
|
||||
focusedAgentId,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [resolvedAgentId, agent?.status, client]);
|
||||
client.clearAgentAttention(clearAgentId);
|
||||
}, [
|
||||
agent?.requiresAttention,
|
||||
client,
|
||||
focusedAgentId,
|
||||
isConnected,
|
||||
resolvedAgentId,
|
||||
]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -898,7 +922,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");
|
||||
@@ -942,7 +966,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
|
||||
@@ -963,7 +990,7 @@ function AgentScreenContent({
|
||||
{isMobile ? (
|
||||
checkout?.isGit ? (
|
||||
<GitBranch
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -972,7 +999,7 @@ function AgentScreenContent({
|
||||
/>
|
||||
) : (
|
||||
<Folder
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -982,7 +1009,7 @@ function AgentScreenContent({
|
||||
)
|
||||
) : (
|
||||
<PanelRight
|
||||
size={16}
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -999,7 +1026,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}>
|
||||
@@ -1118,7 +1148,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 ? (
|
||||
@@ -1150,7 +1180,7 @@ function AgentScreenContent({
|
||||
>
|
||||
{showConnectedNotice ? (
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.palette.green[600]}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1204,7 +1234,10 @@ function AgentScreenContent({
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
|
||||
{agent &&
|
||||
resolvedAgentId &&
|
||||
!shouldBlockForHistorySync &&
|
||||
!isArchivingCurrentAgent && (
|
||||
<AgentInputArea
|
||||
agentId={resolvedAgentId}
|
||||
serverId={serverId}
|
||||
@@ -1225,6 +1258,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>
|
||||
);
|
||||
|
||||
@@ -1386,6 +1429,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
@@ -13,13 +13,13 @@ import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
@@ -27,6 +27,7 @@ import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -34,6 +35,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
shouldShowDesktopPermissionSection,
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
type DesktopPermissionStatus,
|
||||
} from "@/utils/desktop-permissions";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -75,8 +84,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
@@ -84,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: {
|
||||
@@ -179,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,
|
||||
@@ -301,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: {
|
||||
@@ -349,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,
|
||||
@@ -411,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,
|
||||
@@ -436,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
|
||||
@@ -534,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;
|
||||
@@ -590,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.";
|
||||
|
||||
@@ -804,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>
|
||||
@@ -918,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);
|
||||
@@ -983,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
|
||||
@@ -1030,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) => {
|
||||
@@ -1078,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 }}>
|
||||
@@ -1093,7 +1213,7 @@ function HostDetailModal({
|
||||
value={draftLabel}
|
||||
onChangeText={handleDraftLabelChange}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1141,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}
|
||||
@@ -1156,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>
|
||||
@@ -1236,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,
|
||||
@@ -1313,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 =
|
||||
@@ -1336,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
|
||||
@@ -1370,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 }) => [
|
||||
@@ -1383,7 +1571,7 @@ function DaemonCard({
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={16}
|
||||
size={theme.iconSize.md}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
@@ -303,7 +304,7 @@ function buildDownloadUrl(
|
||||
function triggerBrowserDownload(url: string, fileName: string) {
|
||||
if (typeof document === "undefined") {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
60
packages/app/src/utils/agent-attention.test.ts
Normal file
60
packages/app/src/utils/agent-attention.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldClearAgentAttentionOnView } from "./agent-attention";
|
||||
|
||||
describe("shouldClearAgentAttentionOnView", () => {
|
||||
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the app is disconnected", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: false,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the agent is not focused", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-2",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when attention is already clear", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty agent ids", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
22
packages/app/src/utils/agent-attention.ts
Normal file
22
packages/app/src/utils/agent-attention.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
interface ShouldClearAgentAttentionOnViewInput {
|
||||
agentId: string | null | undefined;
|
||||
focusedAgentId: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function shouldClearAgentAttentionOnView(
|
||||
input: ShouldClearAgentAttentionOnViewInput
|
||||
): boolean {
|
||||
const agentId = input.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
return false;
|
||||
}
|
||||
if (!input.requiresAttention) {
|
||||
return false;
|
||||
}
|
||||
return input.focusedAgentId === agentId;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||
|
||||
describe("collectAgentWorkingDirectorySuggestions", () => {
|
||||
it("deduplicates by cwd and sorts by most recent timestamp", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-beta",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
lastActivityAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/project-alpha",
|
||||
"/Users/me/project-beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes Paseo-owned worktree paths", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/repo/.paseo/worktrees/feature-a",
|
||||
createdAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/repo",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/repo"]);
|
||||
});
|
||||
|
||||
it("ignores empty cwd values", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{ cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") },
|
||||
{ cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") },
|
||||
{ cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") },
|
||||
{
|
||||
cwd: "/Users/me/project",
|
||||
createdAt: new Date("2026-02-09T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/project"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface AgentWorkingDirectorySource {
|
||||
cwd?: string | null;
|
||||
createdAt?: Date | null;
|
||||
lastActivityAt?: Date | null;
|
||||
}
|
||||
|
||||
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
|
||||
|
||||
export function collectAgentWorkingDirectorySuggestions(
|
||||
sources: Iterable<AgentWorkingDirectorySource>
|
||||
): string[] {
|
||||
const lastSeenByPath = new Map<string, number>();
|
||||
|
||||
for (const source of sources) {
|
||||
const cwd = source.cwd?.trim();
|
||||
if (!cwd) {
|
||||
continue;
|
||||
}
|
||||
if (isPaseoOwnedWorktreePath(cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt);
|
||||
const previous = lastSeenByPath.get(cwd);
|
||||
if (previous === undefined || timestamp > previous) {
|
||||
lastSeenByPath.set(cwd, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(lastSeenByPath.entries())
|
||||
.sort((left, right) => {
|
||||
const timeDiff = right[1] - left[1];
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})
|
||||
.map(([cwd]) => cwd);
|
||||
}
|
||||
|
||||
function isPaseoOwnedWorktreePath(cwd: string): boolean {
|
||||
return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toEpochMs(date: Date | null | undefined): number {
|
||||
if (!(date instanceof Date)) {
|
||||
return 0;
|
||||
}
|
||||
const value = date.getTime();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
116
packages/app/src/utils/confirm-dialog.test.ts
Normal file
116
packages/app/src/utils/confirm-dialog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
123
packages/app/src/utils/confirm-dialog.ts
Normal file
123
packages/app/src/utils/confirm-dialog.ts
Normal 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);
|
||||
}
|
||||
192
packages/app/src/utils/desktop-permissions.test.ts
Normal file
192
packages/app/src/utils/desktop-permissions.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
387
packages/app/src/utils/desktop-permissions.ts
Normal file
387
packages/app/src/utils/desktop-permissions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
61
packages/app/src/utils/file-mention-autocomplete.test.ts
Normal file
61
packages/app/src/utils/file-mention-autocomplete.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
48
packages/app/src/utils/file-mention-autocomplete.ts
Normal file
48
packages/app/src/utils/file-mention-autocomplete.ts
Normal 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}`
|
||||
}
|
||||
79
packages/app/src/utils/image-attachments-from-files.test.ts
Normal file
79
packages/app/src/utils/image-attachments-from-files.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user