mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f8f4c55976 | ||
|
|
90a81462cb |
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 }}"
|
||||
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
|
||||
|
||||
99
CHANGELOG.md
Normal file
99
CHANGELOG.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.9] - 2026-02-17
|
||||
### Improved
|
||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||
- Reused provider availability checks for structured generation fallback selection.
|
||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||
|
||||
### Fixed
|
||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||
|
||||
## [0.1.8] - 2026-02-17
|
||||
### Added
|
||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||
|
||||
### Improved
|
||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||
- Updated website hero copy to emphasize local execution.
|
||||
|
||||
### Fixed
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## [0.1.7] - 2026-02-16
|
||||
### Added
|
||||
- Improved agent workspace flows with better directory suggestions.
|
||||
- Added iOS TestFlight and Android app access request forms on the website.
|
||||
|
||||
### Improved
|
||||
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||
- Improved website app download and update guidance.
|
||||
|
||||
### Fixed
|
||||
- Prevented an initial desktop combobox `0,0` position flash.
|
||||
- Fixed CLI version output issues.
|
||||
- Hardened server runtime loading for local speech dependencies.
|
||||
|
||||
## [0.1.6] - 2026-02-16
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.5] - 2026-02-16
|
||||
### Added
|
||||
- Added terminal reattach support and better worktree terminal handling.
|
||||
- Added global keyboard shortcut help in the app.
|
||||
- Added sidebar host filtering and improved agent workflow controls.
|
||||
|
||||
### Improved
|
||||
- Improved worktree setup visibility by streaming setup progress.
|
||||
- Improved terminal streaming reliability and lifecycle handling.
|
||||
- Preserved explorer tab state so context survives navigation better.
|
||||
|
||||
## [0.1.4] - 2026-02-14
|
||||
### Added
|
||||
- Added voice capability status reporting in the client.
|
||||
- Added background local speech model downloads with runtime gating.
|
||||
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||
|
||||
### Improved
|
||||
- Improved connection selection and agent hydration reliability.
|
||||
- Improved timeline loading with cursor-based fetch behavior.
|
||||
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||
- Improved inline code rendering by auto-linkifying URLs.
|
||||
|
||||
### Fixed
|
||||
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||
- Fixed stale relay client timer behavior.
|
||||
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||
|
||||
## [0.1.3] - 2026-02-12
|
||||
### Added
|
||||
- Added CLI onboarding command.
|
||||
- Added CLI `--output-schema` support for structured agent output.
|
||||
- Added CLI agent metadata update support for names and labels.
|
||||
- Added provider availability detection with normalization of legacy default model IDs.
|
||||
|
||||
### Improved
|
||||
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||
- Added better voice interrupt handling with a speech-start grace period.
|
||||
- Improved CLI defaults to list all non-archived agents by default.
|
||||
- Improved website UX with clearer install CTA and privacy policy access.
|
||||
|
||||
### Fixed
|
||||
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||
|
||||
## [0.1.2] - 2026-02-11
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.1] - 2026-02-11
|
||||
|
||||
### Added
|
||||
- Initial `0.1.x` release line.
|
||||
@@ -139,6 +139,7 @@ npm run android:production
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
@@ -182,7 +183,7 @@ npm run release:patch
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
@@ -196,6 +197,7 @@ Notes:
|
||||
Release completion checklist:
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- GitHub `Android APK Release` workflow for the same tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -14714,7 +14714,9 @@
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"license": "MIT"
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
@@ -14779,6 +14781,12 @@
|
||||
"version": "2.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"funding": [
|
||||
@@ -14825,6 +14833,16 @@
|
||||
"version": "0.4.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.2.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
|
||||
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"license": "MIT"
|
||||
@@ -20344,7 +20362,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -20352,7 +20370,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.7",
|
||||
"@getpaseo/server": "0.1.14",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -20456,11 +20474,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.7",
|
||||
"@getpaseo/server": "0.1.7",
|
||||
"@getpaseo/relay": "0.1.14",
|
||||
"@getpaseo/server": "0.1.14",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -20510,14 +20528,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -20533,12 +20551,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.7",
|
||||
"@getpaseo/relay": "0.1.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -20558,7 +20576,7 @@
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "^1.0.0",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pino": "^10.2.0",
|
||||
@@ -20802,14 +20820,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -20890,7 +20900,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -30,7 +30,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.7",
|
||||
"@getpaseo/server": "0.1.14",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,7 +97,7 @@ export function DropdownField({
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -196,7 +196,7 @@ export function SelectField({
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -289,7 +289,7 @@ export function DropdownSheet({
|
||||
hitSlop={10}
|
||||
testID="dropdown-sheet-close"
|
||||
>
|
||||
<X size={18} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
@@ -477,7 +477,7 @@ export function FormSelectTrigger({
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -571,7 +571,7 @@ export function AgentConfigRow({
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -585,7 +585,7 @@ export function AgentConfigRow({
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -598,7 +598,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -612,7 +612,7 @@ export function AgentConfigRow({
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
onSelect={onSelectThinkingOption}
|
||||
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -885,6 +885,9 @@ export function WorkingDirectoryDropdown({
|
||||
onSelect={onSelectPath}
|
||||
searchPlaceholder="Search directories..."
|
||||
emptyText={emptyText}
|
||||
allowCustomValue
|
||||
customValuePrefix=""
|
||||
customValueKind="directory"
|
||||
optionsPosition="above-search"
|
||||
title="Working directory"
|
||||
open={isOpen}
|
||||
@@ -1117,16 +1120,16 @@ export function GitOptionsSection({
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
|
||||
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} />
|
||||
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +12,7 @@ import {
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
Archive,
|
||||
@@ -50,8 +50,13 @@ import {
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
// =============================================================================
|
||||
// Git Actions Data Structure
|
||||
@@ -469,9 +474,13 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
@@ -517,6 +526,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
@@ -623,17 +633,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
bodyHeightByPathRef.current[path] = height;
|
||||
}, []);
|
||||
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
const handleDiffListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number => {
|
||||
@@ -796,10 +818,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
@@ -821,15 +847,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
@@ -874,6 +901,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||
const baseRefLabel = useMemo(() => {
|
||||
if (!baseRef) return "base";
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
@@ -887,12 +915,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
? undefined
|
||||
: `${branchLabel} -> ${baseRefLabel}`;
|
||||
}, [baseRefLabel, branchLabel]);
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
const shouldPromoteArchive =
|
||||
isPaseoOwnedWorktree &&
|
||||
!hasUncommittedChanges &&
|
||||
(postShipArchiveSuggested || isMergedPullRequest);
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
const archiveDisabled =
|
||||
@@ -956,7 +999,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? diffScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
@@ -969,11 +1018,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
// ==========================================================================
|
||||
// Git Actions (Data-Oriented)
|
||||
@@ -1063,16 +1110,28 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update from base - only when not on base branch
|
||||
if (!isOnBaseBranch) {
|
||||
// Update/sync from base
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: `Update from ${baseRefLabel}`,
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
@@ -1095,8 +1154,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
if (hasUncommittedChanges) {
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
@@ -1107,7 +1170,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: Ahead of base → Ship action based on preference
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
@@ -1127,20 +1194,25 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: archive worktree only
|
||||
const menu = allActions.has("archive-worktree")
|
||||
? [allActions.get("archive-worktree")!]
|
||||
: [];
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
@@ -1331,7 +1403,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar && hasChanges}
|
||||
metrics={diffScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
diffListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1531,6 +1615,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type LayoutChangeEvent,
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
} from "@/styles/markdown-styles";
|
||||
import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildToolCallDisplayModel,
|
||||
@@ -77,6 +78,7 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
timestamp: number;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
@@ -117,6 +119,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
}
|
||||
`;
|
||||
let webToolCallShimmerRegistered = false;
|
||||
const SCROLL_EDGE_EPSILON = 0.5;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -143,6 +147,113 @@ function ensureWebToolCallShimmerKeyframes() {
|
||||
webToolCallShimmerRegistered = true;
|
||||
}
|
||||
|
||||
function getWheelEventElementTarget(
|
||||
event: WheelEvent,
|
||||
fallback: HTMLElement
|
||||
): HTMLElement {
|
||||
const { target } = event;
|
||||
if (target instanceof HTMLElement) {
|
||||
return target;
|
||||
}
|
||||
if (target instanceof Node && target.parentElement) {
|
||||
return target.parentElement;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function canElementScrollInDirection(
|
||||
element: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||||
const isScrollableOverflow =
|
||||
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||||
if (!isScrollableOverflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||||
const scrollSize =
|
||||
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
|
||||
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
|
||||
function canScrollInsideDetailFromTarget(
|
||||
detailRoot: HTMLElement,
|
||||
startElement: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current: HTMLElement | null = startElement;
|
||||
while (current) {
|
||||
if (canElementScrollInDirection(current, axis, delta)) {
|
||||
return true;
|
||||
}
|
||||
if (current === detailRoot) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldStopDetailWheelPropagation(
|
||||
detailRoot: HTMLElement,
|
||||
event: WheelEvent
|
||||
): boolean {
|
||||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||||
const verticalDelta = event.deltaY;
|
||||
const horizontalDelta =
|
||||
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
|
||||
|
||||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||||
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canScrollVertically = hasVerticalIntent
|
||||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||||
: false;
|
||||
const canScrollHorizontally = hasHorizontalIntent
|
||||
? canScrollInsideDetailFromTarget(
|
||||
detailRoot,
|
||||
startElement,
|
||||
"x",
|
||||
horizontalDelta
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasVerticalIntent && hasHorizontalIntent) {
|
||||
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||||
return isVerticalDominant
|
||||
? canScrollVertically || canScrollHorizontally
|
||||
: canScrollHorizontally || canScrollVertically;
|
||||
}
|
||||
|
||||
if (hasVerticalIntent) {
|
||||
return canScrollVertically;
|
||||
}
|
||||
return canScrollHorizontally;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -177,6 +288,24 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
lineHeight: 22,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imagePreviewSpacing: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
imagePill: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
copyButton: {
|
||||
alignSelf: "flex-end",
|
||||
padding: theme.spacing[1],
|
||||
@@ -192,6 +321,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
message,
|
||||
images = [],
|
||||
timestamp,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
@@ -201,8 +331,10 @@ export const UserMessage = memo(function UserMessage({
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const showCopyButton =
|
||||
Platform.OS !== "web" || messageHovered || copyButtonHovered;
|
||||
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -227,21 +359,45 @@ export const UserMessage = memo(function UserMessage({
|
||||
}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
{hasImages ? (
|
||||
<View
|
||||
style={[
|
||||
userMessageStylesheet.imagePreviewContainer,
|
||||
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||||
]}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<View
|
||||
key={`${image.uri}-${index}`}
|
||||
style={userMessageStylesheet.imagePill}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: image.uri }}
|
||||
style={userMessageStylesheet.imageThumbnail}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<TurnCopyButton
|
||||
getContent={() => message}
|
||||
containerStyle={[
|
||||
userMessageStylesheet.copyButton,
|
||||
showCopyButton
|
||||
? userMessageStylesheet.copyButtonVisible
|
||||
: userMessageStylesheet.copyButtonHidden,
|
||||
]}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setCopyButtonHovered}
|
||||
/>
|
||||
{hasText ? (
|
||||
<TurnCopyButton
|
||||
getContent={() => message}
|
||||
containerStyle={[
|
||||
userMessageStylesheet.copyButton,
|
||||
showCopyButton
|
||||
? userMessageStylesheet.copyButtonVisible
|
||||
: userMessageStylesheet.copyButtonHidden,
|
||||
]}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setCopyButtonHovered}
|
||||
/>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -1121,6 +1277,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const hasDetailContent = Boolean(renderDetails);
|
||||
const detailContent =
|
||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||
const detailWrapperRef = useRef<View | null>(null);
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||
@@ -1240,6 +1397,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
shimmerTranslateX,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||
return () => {
|
||||
node.removeEventListener("wheel", stopWheelPropagation);
|
||||
};
|
||||
}, [isExpanded, hasDetailContent]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
}));
|
||||
@@ -1476,6 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
onHoverIn={() => onDetailHoverChange?.(true)}
|
||||
onHoverOut={() => onDetailHoverChange?.(false)}
|
||||
|
||||
@@ -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);
|
||||
@@ -614,15 +637,17 @@ export function SidebarAgentList({
|
||||
setSelectedBatchKeys(new Set([key]));
|
||||
}, []);
|
||||
|
||||
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => {
|
||||
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.archiveAgent(entry.agent.id).catch((error) => {
|
||||
console.warn("[archive_agent] failed", error);
|
||||
});
|
||||
}, []);
|
||||
const handleArchiveSingle = useCallback(
|
||||
async (entry: SidebarAgentListEntry): Promise<void> => {
|
||||
await archiveAgent({
|
||||
serverId: entry.agent.serverId,
|
||||
agentId: entry.agent.id,
|
||||
}).catch((error) => {
|
||||
console.warn("[archive_agent] failed", error);
|
||||
});
|
||||
},
|
||||
[archiveAgent]
|
||||
);
|
||||
|
||||
const handleArchiveBatch = useCallback(() => {
|
||||
if (selectedBatchKeys.size === 0) {
|
||||
@@ -631,23 +656,18 @@ export function SidebarAgentList({
|
||||
}
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
const store = useSessionStore.getState();
|
||||
for (const key of selectedBatchKeys) {
|
||||
const parsed = parseSidebarAgentKey(key);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const client = store.sessions[parsed.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
continue;
|
||||
}
|
||||
requests.push(
|
||||
client
|
||||
.archiveAgent(parsed.agentId)
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
console.warn("[archive_agent_batch] failed", { key, error });
|
||||
})
|
||||
archiveAgent({
|
||||
serverId: parsed.serverId,
|
||||
agentId: parsed.agentId,
|
||||
}).catch((error) => {
|
||||
console.warn("[archive_agent_batch] failed", { key, error });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -655,7 +675,7 @@ export function SidebarAgentList({
|
||||
setSelectedBatchKeys(new Set());
|
||||
setIsSelectionMode(false);
|
||||
});
|
||||
}, [selectedBatchKeys]);
|
||||
}, [archiveAgent, selectedBatchKeys]);
|
||||
|
||||
const handleSelectionBack = useCallback(() => {
|
||||
setSelectedBatchKeys(new Set());
|
||||
@@ -680,6 +700,10 @@ export function SidebarAgentList({
|
||||
isSelected={selectedAgentId === key}
|
||||
isInSelectionMode={isSelectionMode}
|
||||
isBatchSelected={selectedBatchKeys.has(key)}
|
||||
isArchiving={isArchivingAgent({
|
||||
serverId: item.agent.serverId,
|
||||
agentId: item.agent.id,
|
||||
})}
|
||||
shortcutNumber={
|
||||
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
|
||||
}
|
||||
@@ -704,6 +728,7 @@ export function SidebarAgentList({
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
handleArchiveSingle,
|
||||
isArchivingAgent,
|
||||
isSelectionMode,
|
||||
selectedAgentId,
|
||||
selectedBatchKeys,
|
||||
@@ -760,7 +785,7 @@ export function SidebarAgentList({
|
||||
{selectedProjectLabel}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
size={theme.iconSize.sm}
|
||||
color={
|
||||
showActiveForeground
|
||||
? theme.colors.foreground
|
||||
@@ -820,10 +845,12 @@ export function SidebarAgentList({
|
||||
styles.listContent,
|
||||
isSelectionMode ? styles.listContentSelectionMode : null,
|
||||
]}
|
||||
testID="sidebar-agent-list-scroll"
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderRow}
|
||||
onDragEnd={() => {}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
enableDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -889,13 +916,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
selectedProjectIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectCountBadge: {
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
minWidth: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -960,13 +987,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
projectIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1012,8 +1039,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 20,
|
||||
},
|
||||
checkbox: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1073,8 +1100,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
agentMetaProjectIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
agentMetaProjectIconInactive: {
|
||||
|
||||
@@ -325,8 +325,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
|
||||
// Render mobile sidebar
|
||||
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
|
||||
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
|
||||
// On web, keep the overlay interactive only while the sidebar is open.
|
||||
// This preserves swipe/scroll behavior without blocking taps when closed.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -355,7 +356,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -417,7 +421,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -434,7 +438,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -478,7 +482,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -538,7 +545,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -554,7 +561,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
onPress={handleSettingsDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Settings
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
@@ -45,6 +47,7 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -159,6 +162,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
||||
null
|
||||
@@ -253,6 +257,30 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!selectedTerminalId) {
|
||||
return;
|
||||
}
|
||||
// Navigation transitions can temporarily report stale dimensions.
|
||||
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||
setTimeout(() => {
|
||||
requestTerminalReflow();
|
||||
}, delayMs)
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const handle of timeoutHandles) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [requestTerminalReflow, selectedTerminalId])
|
||||
);
|
||||
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
@@ -561,13 +589,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}, [activeStreamId, flushPendingTerminalInput]);
|
||||
|
||||
const handleCloseTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
async (terminalId: string) => {
|
||||
if (
|
||||
killTerminalMutation.isPending &&
|
||||
killTerminalMutation.variables === terminalId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Close terminal?",
|
||||
message: "Any running process in this terminal will be stopped immediately.",
|
||||
confirmLabel: "Close",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
killTerminalMutation.mutate(terminalId);
|
||||
},
|
||||
[killTerminalMutation]
|
||||
@@ -855,7 +896,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCloseTerminal(terminal.id);
|
||||
void handleCloseTerminal(terminal.id);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.terminalTabCloseButton,
|
||||
@@ -938,6 +979,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getCurrentTauriWindow } from "@/utils/tauri";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getFileExtension(path: string): string {
|
||||
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
|
||||
function filePathToImageAttachment(path: string): ImageAttachment {
|
||||
const extension = getFileExtension(path);
|
||||
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
|
||||
const convertFileSrc = getTauri()?.core?.convertFileSrc;
|
||||
const uri =
|
||||
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
||||
|
||||
@@ -128,13 +125,28 @@ export function useFileDropZone({
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup(unlisten?: () => void | Promise<void>) {
|
||||
if (didCleanup) return;
|
||||
const cleanupFn = unlisten ?? cleanup;
|
||||
if (!cleanupFn) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupTauriDragDrop(): Promise<boolean> {
|
||||
if (!isTauriEnvironment()) {
|
||||
if (getTauri() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
@@ -171,7 +183,7 @@ export function useFileDropZone({
|
||||
);
|
||||
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
runCleanup(unlisten);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -271,7 +283,7 @@ export function useFileDropZone({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
runCleanup();
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
|
||||
@@ -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,14 +62,17 @@ import {
|
||||
useCheckoutStatusQuery,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -88,6 +87,14 @@ const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const RECONNECT_NOTICE_DELAY_MS = 10_000;
|
||||
const CONNECTED_NOTICE_DURATION_MS = 2_500;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AgentExplorer] ${event}`, details);
|
||||
}
|
||||
|
||||
export function AgentReadyScreen({
|
||||
serverId,
|
||||
@@ -220,6 +227,7 @@ function AgentScreenContent({
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const resolvedAgentId = agentId;
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
@@ -321,31 +329,27 @@ function AgentScreenContent({
|
||||
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
|
||||
const openExplorerForActiveCheckout = useCallback(() => {
|
||||
const checkoutContext = resolveCurrentExplorerCheckout();
|
||||
logAgentExplorer("openExplorerForActiveCheckout", {
|
||||
hasCheckoutContext: Boolean(checkoutContext),
|
||||
checkoutContext,
|
||||
});
|
||||
if (checkoutContext) {
|
||||
activateExplorerTabForCheckout(checkoutContext);
|
||||
}
|
||||
openFileExplorer();
|
||||
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
logAgentExplorer("handleToggleExplorer", {
|
||||
isExplorerOpen,
|
||||
mobileView,
|
||||
isMobile,
|
||||
});
|
||||
if (isExplorerOpen) {
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
openExplorerForActiveCheckout();
|
||||
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
|
||||
|
||||
const {
|
||||
translateX: explorerTranslateX,
|
||||
backdropOpacity: explorerBackdropOpacity,
|
||||
windowWidth: explorerWindowWidth,
|
||||
animateToOpen: animateExplorerToOpen,
|
||||
animateToClose: animateExplorerToClose,
|
||||
isGesturing: isExplorerGesturing,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const handleOpenExplorerFromGesture = useCallback(() => {
|
||||
openExplorerForActiveCheckout();
|
||||
animateExplorerToOpen();
|
||||
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
|
||||
}, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -357,53 +361,10 @@ function AgentScreenContent({
|
||||
}, [serverId, agentId]);
|
||||
|
||||
// Swipe-left gesture to open explorer sidebar on mobile
|
||||
const explorerOpenGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(isMobile && !isExplorerOpen)
|
||||
// Only activate after 15px horizontal movement to the left (negative)
|
||||
.activeOffsetX(-15)
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
.onStart(() => {
|
||||
isExplorerGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Right sidebar: start from closed position (+windowWidth) and move towards 0
|
||||
// Swiping left means negative translationX
|
||||
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
|
||||
explorerTranslateX.value = newTranslateX;
|
||||
explorerBackdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[explorerWindowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isExplorerGesturing.value = false;
|
||||
// Open if dragged more than 1/3 of window or fast swipe left
|
||||
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldOpen) {
|
||||
runOnJS(handleOpenExplorerFromGesture)();
|
||||
} else {
|
||||
animateExplorerToClose();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isExplorerGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
isMobile,
|
||||
isExplorerOpen,
|
||||
explorerWindowWidth,
|
||||
explorerTranslateX,
|
||||
explorerBackdropOpacity,
|
||||
animateExplorerToClose,
|
||||
handleOpenExplorerFromGesture,
|
||||
isExplorerGesturing,
|
||||
]
|
||||
);
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled: isMobile && mobileView === "agent",
|
||||
onOpen: openExplorerForActiveCheckout,
|
||||
});
|
||||
|
||||
// Handle hardware back button - close explorer sidebar first, then navigate back
|
||||
useEffect(() => {
|
||||
@@ -493,6 +454,7 @@ function AgentScreenContent({
|
||||
(state) => state.sessions[serverId]?.pendingPermissions
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setPendingPermissions = useSessionStore(
|
||||
(state) => state.setPendingPermissions
|
||||
);
|
||||
@@ -603,6 +565,10 @@ function AgentScreenContent({
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
const isArchivingCurrentAgent = Boolean(
|
||||
resolvedAgentId &&
|
||||
isArchivingAgent({ serverId, agentId: resolvedAgentId })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -641,6 +607,9 @@ function AgentScreenContent({
|
||||
id: pendingCreate.messageId,
|
||||
text: pendingCreate.text,
|
||||
timestamp: new Date(pendingCreate.timestamp),
|
||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||
? { images: pendingCreate.images }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}, [isPendingCreateForRoute, pendingCreate]);
|
||||
@@ -722,6 +691,32 @@ function AgentScreenContent({
|
||||
(item.id === pendingCreate.messageId || item.text === pendingCreate.text)
|
||||
);
|
||||
if (agent && hasUserMessage) {
|
||||
if (
|
||||
resolvedAgentId &&
|
||||
pendingCreate.images &&
|
||||
pendingCreate.images.length > 0
|
||||
) {
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const current = prev.get(resolvedAgentId);
|
||||
if (!current) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const merged = mergePendingCreateImages({
|
||||
streamItems: current,
|
||||
messageId: pendingCreate.messageId,
|
||||
text: pendingCreate.text,
|
||||
images: pendingCreate.images,
|
||||
});
|
||||
if (merged === current) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(resolvedAgentId, merged);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
clearPendingCreate();
|
||||
}
|
||||
}, [
|
||||
@@ -729,6 +724,9 @@ function AgentScreenContent({
|
||||
clearPendingCreate,
|
||||
isPendingCreateForRoute,
|
||||
pendingCreate,
|
||||
resolvedAgentId,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
streamItems,
|
||||
]);
|
||||
|
||||
@@ -907,7 +905,7 @@ function AgentScreenContent({
|
||||
await Clipboard.setStringAsync(value);
|
||||
toast.show(`Copied ${label}`, {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={16} color={theme.colors.primary} />,
|
||||
icon: <CheckCircle2 size={theme.iconSize.md} color={theme.colors.primary} />,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
@@ -951,7 +949,10 @@ function AgentScreenContent({
|
||||
|
||||
const mainContent = (
|
||||
<View style={styles.outerContainer}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}>
|
||||
<FileDropZone
|
||||
onFilesDropped={handleFilesDropped}
|
||||
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<MenuHeader
|
||||
@@ -972,7 +973,7 @@ function AgentScreenContent({
|
||||
{isMobile ? (
|
||||
checkout?.isGit ? (
|
||||
<GitBranch
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -981,7 +982,7 @@ function AgentScreenContent({
|
||||
/>
|
||||
) : (
|
||||
<Folder
|
||||
size={20}
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -991,7 +992,7 @@ function AgentScreenContent({
|
||||
)
|
||||
) : (
|
||||
<PanelRight
|
||||
size={16}
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
@@ -1008,7 +1009,10 @@ function AgentScreenContent({
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
|
||||
<MoreVertical size={isMobile ? 20 : 16} color={theme.colors.foregroundMuted} />
|
||||
<MoreVertical
|
||||
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
|
||||
<View style={styles.menuMetaContainer}>
|
||||
@@ -1127,7 +1131,7 @@ function AgentScreenContent({
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
|
||||
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
|
||||
disabled={isInitializing || shouldBlockForHistorySync}
|
||||
trailing={
|
||||
isInitializing ? (
|
||||
@@ -1159,7 +1163,7 @@ function AgentScreenContent({
|
||||
>
|
||||
{showConnectedNotice ? (
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.palette.green[600]}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1213,7 +1217,10 @@ function AgentScreenContent({
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
|
||||
{agent &&
|
||||
resolvedAgentId &&
|
||||
!shouldBlockForHistorySync &&
|
||||
!isArchivingCurrentAgent && (
|
||||
<AgentInputArea
|
||||
agentId={resolvedAgentId}
|
||||
serverId={serverId}
|
||||
@@ -1234,6 +1241,16 @@ function AgentScreenContent({
|
||||
isGit={isGitCheckout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ActivityIndicator size="large" color={theme.colors.foreground} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>
|
||||
Please wait while we archive this agent.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1395,6 +1412,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
archivingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: "rgba(8, 10, 14, 0.86)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[8],
|
||||
gap: theme.spacing[3],
|
||||
zIndex: 50,
|
||||
},
|
||||
archivingTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
archivingSubtitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,13 @@ import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
@@ -27,6 +27,7 @@ import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -34,6 +35,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
shouldShowDesktopPermissionSection,
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
type DesktopPermissionStatus,
|
||||
} from "@/utils/desktop-permissions";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -75,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
@@ -83,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
@@ -178,6 +185,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
versionPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
maxWidth: 200,
|
||||
},
|
||||
hostSettingsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -300,39 +318,58 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
audioRowDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Footer
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[4],
|
||||
permissionSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
footerAppInfo: {
|
||||
permissionRefreshButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
permissionRefreshButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
permissionRowActions: {
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
permissionStatusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 4,
|
||||
minWidth: 88,
|
||||
justifyContent: "center",
|
||||
},
|
||||
footerVersion: {
|
||||
permissionStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
permissionDetailText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
maxWidth: 220,
|
||||
textAlign: "right",
|
||||
},
|
||||
resetButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
resetButtonText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
// Empty state
|
||||
emptyCard: {
|
||||
@@ -348,35 +385,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
// Theme toggle
|
||||
themeToggleContainer: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: 4,
|
||||
gap: 4,
|
||||
},
|
||||
themeToggleButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
themeToggleButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
themeToggleText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
themeToggleTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// Dev section
|
||||
devCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -410,13 +418,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function resolveAppVersion(): string | null {
|
||||
const expoVersion = Constants.expoConfig?.version;
|
||||
if (typeof expoVersion === "string" && expoVersion.trim().length > 0) {
|
||||
return expoVersion.trim();
|
||||
}
|
||||
|
||||
const manifestVersion = (Constants as any).manifest?.version;
|
||||
if (typeof manifestVersion === "string" && manifestVersion.trim().length > 0) {
|
||||
return manifestVersion.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
const daemonVersion = version?.trim();
|
||||
if (!daemonVersion) {
|
||||
return null;
|
||||
}
|
||||
if (daemonVersion.startsWith("v")) {
|
||||
return daemonVersion;
|
||||
}
|
||||
return `v${daemonVersion}`;
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
const {
|
||||
daemons,
|
||||
isLoading: daemonLoading,
|
||||
@@ -435,10 +467,17 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
|
||||
useState<DesktopPermissionSnapshot | null>(null);
|
||||
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
|
||||
useState(false);
|
||||
const [requestingDesktopPermission, setRequestingDesktopPermission] =
|
||||
useState<DesktopPermissionKind | null>(null);
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||
const appVersion = resolveAppVersion();
|
||||
const editingServerId = editingDaemon?.serverId ?? null;
|
||||
const editingDaemonLive = editingServerId
|
||||
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
||||
@@ -533,6 +572,73 @@ export default function SettingsScreen() {
|
||||
pendingEditReopenServerId,
|
||||
]);
|
||||
|
||||
const refreshDesktopPermissions = useCallback(async () => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
|
||||
setIsRefreshingDesktopPermissions(true);
|
||||
try {
|
||||
const snapshot = await getDesktopPermissionSnapshot();
|
||||
if (!isMountedRef.current) return;
|
||||
setDesktopPermissionSnapshot(snapshot);
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to load desktop permission status", error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsRefreshingDesktopPermissions(false);
|
||||
}
|
||||
}
|
||||
}, [showDesktopPermissionSection]);
|
||||
|
||||
const handleRequestDesktopPermission = useCallback(
|
||||
async (kind: DesktopPermissionKind) => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
|
||||
setRequestingDesktopPermission(kind);
|
||||
try {
|
||||
const status = await requestDesktopPermission({ kind });
|
||||
if (!isMountedRef.current) return;
|
||||
setDesktopPermissionSnapshot((previous) => {
|
||||
const base: DesktopPermissionSnapshot = previous ?? {
|
||||
checkedAt: Date.now(),
|
||||
notifications: {
|
||||
state: "unknown",
|
||||
detail: "Notification status has not been checked yet.",
|
||||
},
|
||||
microphone: {
|
||||
state: "unknown",
|
||||
detail: "Microphone status has not been checked yet.",
|
||||
},
|
||||
};
|
||||
|
||||
return kind === "notifications"
|
||||
? {
|
||||
...base,
|
||||
checkedAt: Date.now(),
|
||||
notifications: status,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
checkedAt: Date.now(),
|
||||
microphone: status,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Failed to request ${kind} permission`, error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setRequestingDesktopPermission(null);
|
||||
}
|
||||
await refreshDesktopPermissions();
|
||||
}
|
||||
},
|
||||
[refreshDesktopPermissions, showDesktopPermissionSection]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
void refreshDesktopPermissions();
|
||||
}, [refreshDesktopPermissions, showDesktopPermissionSection]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||
if (!editingServerId) return;
|
||||
if (isSavingEdit) return;
|
||||
@@ -589,34 +695,6 @@ export default function SettingsScreen() {
|
||||
[updateSettings]
|
||||
);
|
||||
|
||||
function handleReset() {
|
||||
Alert.alert(
|
||||
"Reset settings",
|
||||
"Are you sure you want to reset all settings to defaults?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Reset",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await resetSettings();
|
||||
Alert.alert(
|
||||
"Settings reset",
|
||||
"All settings have been reset to defaults."
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to reset settings. Please try again."
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const restartConfirmationMessage =
|
||||
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
||||
|
||||
@@ -803,55 +881,97 @@ export default function SettingsScreen() {
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||
<View style={styles.themeToggleContainer}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "light" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("light")}
|
||||
>
|
||||
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
||||
Light
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "dark" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("dark")}
|
||||
>
|
||||
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
||||
Dark
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "auto" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("auto")}
|
||||
>
|
||||
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
||||
System
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={settings.theme}
|
||||
onValueChange={handleThemeChange}
|
||||
options={[
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: ({ color, size }) => <Sun size={size} color={color} />,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: ({ color, size }) => <Moon size={size} color={color} />,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "System",
|
||||
icon: ({ color, size }) => <Monitor size={size} color={color} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerAppInfo}>
|
||||
<Text style={styles.footerText}>Paseo</Text>
|
||||
<Text style={styles.footerVersion}>Version {appVersion}</Text>
|
||||
{showDesktopPermissionSection ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.permissionSectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
|
||||
Desktop permissions
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.permissionRefreshButton,
|
||||
(isRefreshingDesktopPermissions ||
|
||||
requestingDesktopPermission !== null) &&
|
||||
styles.permissionRefreshButtonDisabled,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
onPress={() => {
|
||||
void refreshDesktopPermissions();
|
||||
}}
|
||||
disabled={
|
||||
isRefreshingDesktopPermissions ||
|
||||
requestingDesktopPermission !== null
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
>
|
||||
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.audioCard}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
status={desktopPermissionSnapshot?.notifications ?? null}
|
||||
isRequesting={requestingDesktopPermission === "notifications"}
|
||||
onRequest={() => {
|
||||
void handleRequestDesktopPermission("notifications");
|
||||
}}
|
||||
/>
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
showBorder
|
||||
status={desktopPermissionSnapshot?.microphone ?? null}
|
||||
isRequesting={requestingDesktopPermission === "microphone"}
|
||||
onRequest={() => {
|
||||
void handleRequestDesktopPermission("microphone");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Version</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||
<Text style={styles.resetButtonText}>Reset to defaults</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -917,6 +1037,7 @@ function HostDetailModal({
|
||||
// Restart logic (moved from DaemonCard)
|
||||
const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null);
|
||||
const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null);
|
||||
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
|
||||
const isConnected = daemonConnection?.isConnected ?? false;
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
@@ -982,29 +1103,21 @@ function HostDetailModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof (globalThis as any).confirm === "function";
|
||||
|
||||
const confirmed = hasBrowserConfirm
|
||||
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
|
||||
: true;
|
||||
|
||||
if (confirmed) {
|
||||
beginServerRestart();
|
||||
void confirmDialog({
|
||||
title: `Restart ${host.label}`,
|
||||
message: restartConfirmationMessage,
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restart",
|
||||
style: "destructive",
|
||||
onPress: beginServerRestart,
|
||||
},
|
||||
]);
|
||||
beginServerRestart();
|
||||
}).catch((error) => {
|
||||
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||
});
|
||||
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
||||
|
||||
// Status display
|
||||
@@ -1029,13 +1142,14 @@ function HostDetailModal({
|
||||
const connectionBadge = (() => {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
})();
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
|
||||
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||
@@ -1077,6 +1191,13 @@ function HostDetailModal({
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{versionBadgeText ? (
|
||||
<View style={styles.versionPill}>
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{connectionError ? (
|
||||
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
|
||||
@@ -1092,7 +1213,7 @@ function HostDetailModal({
|
||||
value={draftLabel}
|
||||
onChangeText={handleDraftLabelChange}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1140,13 +1261,13 @@ function HostDetailModal({
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
>
|
||||
<Settings size={14} color={theme.colors.foregroundMuted} />
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedTriggerText}>Advanced</Text>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" width={220}>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleRestartPress}
|
||||
leading={<RotateCw size={16} color={theme.colors.foregroundMuted} />}
|
||||
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
status={isRestarting ? "pending" : "idle"}
|
||||
pendingLabel="Restarting..."
|
||||
disabled={!daemonClient || !isConnectedRef.current}
|
||||
@@ -1155,7 +1276,7 @@ function HostDetailModal({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => { if (host) onRemoveHost(host); }}
|
||||
leading={<Trash2 size={16} color={theme.colors.destructive} />}
|
||||
leading={<Trash2 size={theme.iconSize.md} color={theme.colors.destructive} />}
|
||||
>
|
||||
Remove host
|
||||
</DropdownMenuItem>
|
||||
@@ -1235,6 +1356,60 @@ function HostDetailModal({
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopPermissionRowProps {
|
||||
title: string;
|
||||
status: DesktopPermissionStatus | null;
|
||||
isRequesting: boolean;
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
}
|
||||
|
||||
function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
isRequesting,
|
||||
showBorder,
|
||||
onRequest,
|
||||
}: DesktopPermissionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const state = status?.state ?? "unknown";
|
||||
const isGranted = state === "granted";
|
||||
const shouldShowDetail =
|
||||
status !== null &&
|
||||
status.detail.trim().length > 0 &&
|
||||
state !== "granted" &&
|
||||
state !== "prompt" &&
|
||||
state !== "not-granted";
|
||||
|
||||
return (
|
||||
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.permissionRowActions}>
|
||||
{isGranted ? (
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={onRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowDetail ? (
|
||||
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
latencyMs,
|
||||
@@ -1312,6 +1487,12 @@ function DaemonCard({
|
||||
onOpenSettings,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemonVersion = useSessionStore(
|
||||
useCallback(
|
||||
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
|
||||
[daemon.serverId]
|
||||
)
|
||||
);
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||
const statusColor =
|
||||
@@ -1335,13 +1516,14 @@ function DaemonCard({
|
||||
const connectionBadge = (() => {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
})();
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -1369,6 +1551,13 @@ function DaemonCard({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{versionBadgeText ? (
|
||||
<View style={styles.versionPill}>
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
@@ -1382,7 +1571,7 @@ function DaemonCard({
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={16}
|
||||
size={theme.iconSize.md}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
67
packages/app/src/utils/image-attachments-from-files.ts
Normal file
67
packages/app/src/utils/image-attachments-from-files.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface ClipboardItemLike {
|
||||
kind?: string;
|
||||
type?: string;
|
||||
getAsFile?: () => File | null;
|
||||
}
|
||||
|
||||
export interface ClipboardDataLike {
|
||||
items?: ArrayLike<ClipboardItemLike> | null;
|
||||
}
|
||||
|
||||
export interface ImageAttachmentFromFile {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
let binary = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function collectImageFilesFromClipboardData(
|
||||
clipboardData?: ClipboardDataLike | null
|
||||
): File[] {
|
||||
if (!clipboardData?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(clipboardData.items)) {
|
||||
if (item?.kind !== "file") {
|
||||
continue;
|
||||
}
|
||||
if (!item.type?.startsWith("image/")) {
|
||||
continue;
|
||||
}
|
||||
const file = item.getAsFile?.();
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function filesToImageAttachments(
|
||||
files: readonly File[]
|
||||
): Promise<ImageAttachmentFromFile[]> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const mimeType = file.type || "image/jpeg";
|
||||
const base64 = toBase64(await file.arrayBuffer());
|
||||
return {
|
||||
uri: `data:${mimeType};base64,${base64}`,
|
||||
mimeType,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,18 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
|
||||
});
|
||||
|
||||
it("falls back to repo root when checkout metadata is unavailable", () => {
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe(
|
||||
"/repo"
|
||||
);
|
||||
});
|
||||
|
||||
it("supports windows-style paseo worktree paths without checkout metadata", () => {
|
||||
expect(
|
||||
resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)
|
||||
).toBe("C:\\Users\\me\\repo");
|
||||
});
|
||||
|
||||
it("returns the main repo root for paseo-owned worktrees", () => {
|
||||
const checkout = {
|
||||
isPaseoOwnedWorktree: true,
|
||||
|
||||
@@ -32,11 +32,34 @@ export function resolveSelectedAgentForNewAgent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
|
||||
const normalizedPath = cwd.replace(/\\/g, "/");
|
||||
const marker = "/.paseo/worktrees";
|
||||
const markerIndex = normalizedPath.indexOf(marker);
|
||||
if (markerIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const markerEnd = markerIndex + marker.length;
|
||||
const nextChar = normalizedPath[markerEnd];
|
||||
if (nextChar && nextChar !== "/") {
|
||||
return null;
|
||||
}
|
||||
const inferred = cwd.slice(0, markerIndex).replace(/[\\/]+$/, "");
|
||||
return inferred.trim() ? inferred : null;
|
||||
}
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
checkout: CheckoutStatusPayload | null
|
||||
): string {
|
||||
return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd;
|
||||
const explicitMainRepoRoot = checkout?.isPaseoOwnedWorktree
|
||||
? checkout.mainRepoRoot?.trim() || null
|
||||
: null;
|
||||
if (explicitMainRepoRoot) {
|
||||
return explicitMainRepoRoot;
|
||||
}
|
||||
|
||||
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
|
||||
}
|
||||
|
||||
export function buildNewAgentRoute(
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
|
||||
interface TauriWindowWithOpener {
|
||||
__TAURI__?: {
|
||||
opener?: {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && getIsTauri()) {
|
||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
const opener = getTauri()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
|
||||
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
__TAURI__: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
|
||||
it("uses Tauri notification module when available", async () => {
|
||||
const isPermissionGranted = vi.fn(async () => false);
|
||||
const requestPermission = vi.fn(async () => "granted");
|
||||
const sendNotification = vi.fn(async () => undefined);
|
||||
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
},
|
||||
};
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1);
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri()?.notification ?? null;
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
permission: string;
|
||||
requestPermission?: () => Promise<string>;
|
||||
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
return await ensureTauriNotificationPermission(tauriNotification);
|
||||
}
|
||||
|
||||
return await ensureNotificationPermission();
|
||||
}
|
||||
|
||||
async function ensureTauriNotificationPermission(
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.isPermissionGranted === "function") {
|
||||
try {
|
||||
const granted = await notificationModule.isPermissionGranted();
|
||||
if (granted) {
|
||||
console.log("[OSNotifications][Tauri] Permission already granted");
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to check notification permission",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof notificationModule.requestPermission !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await notificationModule.requestPermission();
|
||||
console.log("[OSNotifications][Tauri] requestPermission result:", result);
|
||||
return result === "granted";
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to request notification permission",
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTauriNotification(
|
||||
payload: OsNotificationPayload,
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.sendNotification !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.sendNotification is unavailable"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const granted = await ensureTauriNotificationPermission(notificationModule);
|
||||
if (!granted) {
|
||||
console.log("[OSNotifications][Tauri] Permission not granted");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await notificationModule.sendNotification({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[OSNotifications][Tauri] Failed to send notification", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
@@ -127,12 +215,28 @@ export async function sendOsNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
console.log("[OSNotifications] Using Tauri notification module");
|
||||
return await sendTauriNotification(payload, tauriNotification);
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor();
|
||||
if (!NotificationConstructor) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Notification constructor unavailable",
|
||||
typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "unknown-origin"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const granted = await ensureNotificationPermission();
|
||||
const granted = await ensureOsNotificationPermission();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Permission not granted:",
|
||||
NotificationConstructor.permission
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
|
||||
88
packages/app/src/utils/pending-create-images.test.ts
Normal file
88
packages/app/src/utils/pending-create-images.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { mergePendingCreateImages } from "./pending-create-images";
|
||||
|
||||
function userMessage(params: {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: Array<{ uri: string; mimeType: string }>;
|
||||
}): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: params.id,
|
||||
text: params.text,
|
||||
timestamp: new Date("2026-01-01T00:00:00Z"),
|
||||
...(params.images ? { images: params.images } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergePendingCreateImages", () => {
|
||||
it("returns same reference when pending images are absent", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
images: [],
|
||||
});
|
||||
expect(result).toBe(streamItems);
|
||||
});
|
||||
|
||||
it("merges images by messageId when the matched message has none", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
const images = [{ uri: "file:///tmp/image-1.jpg", mimeType: "image/jpeg" }];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
images,
|
||||
});
|
||||
|
||||
expect(result).not.toBe(streamItems);
|
||||
const updated = result[0];
|
||||
expect(updated?.kind).toBe("user_message");
|
||||
if (updated?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(updated.images).toEqual(images);
|
||||
});
|
||||
|
||||
it("falls back to text matching when messageId does not match", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
|
||||
const images = [{ uri: "file:///tmp/image-2.jpg", mimeType: "image/jpeg" }];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "missing-id",
|
||||
text: "same text",
|
||||
images,
|
||||
});
|
||||
|
||||
const updated = result[0];
|
||||
expect(updated?.kind).toBe("user_message");
|
||||
if (updated?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(updated.images).toEqual(images);
|
||||
});
|
||||
|
||||
it("does not overwrite existing user message images", () => {
|
||||
const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }];
|
||||
const streamItems = [
|
||||
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
|
||||
];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }],
|
||||
});
|
||||
|
||||
expect(result).toBe(streamItems);
|
||||
const unchanged = result[0];
|
||||
expect(unchanged?.kind).toBe("user_message");
|
||||
if (unchanged?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(unchanged.images).toEqual(existingImages);
|
||||
});
|
||||
});
|
||||
39
packages/app/src/utils/pending-create-images.ts
Normal file
39
packages/app/src/utils/pending-create-images.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { StreamItem, UserMessageImageAttachment } from "@/types/stream";
|
||||
|
||||
type MergePendingCreateImagesParams = {
|
||||
streamItems: StreamItem[];
|
||||
messageId: string;
|
||||
text: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
};
|
||||
|
||||
export function mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId,
|
||||
text,
|
||||
images,
|
||||
}: MergePendingCreateImagesParams): StreamItem[] {
|
||||
if (!images || images.length === 0) {
|
||||
return streamItems;
|
||||
}
|
||||
|
||||
const targetIndex = streamItems.findIndex(
|
||||
(item) =>
|
||||
item.kind === "user_message" && (item.id === messageId || item.text === text)
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return streamItems;
|
||||
}
|
||||
|
||||
const target = streamItems[targetIndex];
|
||||
if (target.kind !== "user_message") {
|
||||
return streamItems;
|
||||
}
|
||||
if (target.images && target.images.length > 0) {
|
||||
return streamItems;
|
||||
}
|
||||
|
||||
const next = [...streamItems];
|
||||
next[targetIndex] = { ...target, images };
|
||||
return next;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
|
||||
return {
|
||||
serverId: "srv-1",
|
||||
hostname: "test-host",
|
||||
version: "0.1.0",
|
||||
...(capabilities ? { capabilities } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
|
||||
return Array.from(data);
|
||||
}
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
const ws = (window as any).__TAURI__?.websocket;
|
||||
const ws = getTauri()?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
if (getTauri() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
|
||||
// Runtime check for Tauri environment
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" &&
|
||||
(window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
|
||||
@@ -19,15 +14,18 @@ async function getTauriWindow() {
|
||||
}
|
||||
|
||||
try {
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`.
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
|
||||
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
|
||||
// the native (Hermes) builds and break parsing/runtime on mobile.
|
||||
const tauri = (window as any).__TAURI__ as any;
|
||||
const tauri = getTauri();
|
||||
if (!tauri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the public global window module.
|
||||
const windowModule = tauri?.window;
|
||||
if (windowModule && typeof windowModule.getCurrentWindow === "function") {
|
||||
tauriWindow = windowModule.getCurrentWindow();
|
||||
const publicWindow = getCurrentTauriWindow();
|
||||
if (publicWindow) {
|
||||
tauriWindow = publicWindow;
|
||||
return tauriWindow;
|
||||
}
|
||||
|
||||
@@ -109,7 +107,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
@@ -117,19 +129,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
unlisten = await win.onResized(async () => {
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fs = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
92
packages/app/src/utils/tauri.ts
Normal file
92
packages/app/src/utils/tauri.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type TauriNotificationPermission = "granted" | "denied" | "default";
|
||||
|
||||
export interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
export interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
convertFileSrc?: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface TauriEventApi {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (event: unknown) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowApi {
|
||||
label?: string;
|
||||
startDragging?: () => Promise<void>;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
setBadgeCount?: (count?: number) => Promise<void>;
|
||||
onDragDropEvent?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowModule {
|
||||
getCurrentWindow?: () => TauriWindowApi;
|
||||
}
|
||||
|
||||
export interface TauriOpenerApi {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriNotificationApi {
|
||||
isPermissionGranted?: () => Promise<boolean>;
|
||||
requestPermission?: () => Promise<TauriNotificationPermission>;
|
||||
sendNotification?: (
|
||||
payload: string | { title: string; body?: string }
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriWebSocketApi {
|
||||
connect?: (url: string, config?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface TauriApi {
|
||||
core?: TauriCoreApi;
|
||||
dialog?: TauriDialogApi;
|
||||
event?: TauriEventApi;
|
||||
notification?: TauriNotificationApi;
|
||||
opener?: TauriOpenerApi;
|
||||
websocket?: TauriWebSocketApi;
|
||||
window?: TauriWindowModule;
|
||||
}
|
||||
|
||||
export function getTauri(): TauriApi | null {
|
||||
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
if (!tauri || typeof tauri !== "object") {
|
||||
return null;
|
||||
}
|
||||
return tauri as TauriApi;
|
||||
}
|
||||
|
||||
export function isTauriEnvironment(): boolean {
|
||||
return getTauri() !== null;
|
||||
}
|
||||
|
||||
export function getCurrentTauriWindow(): TauriWindowApi | null {
|
||||
const getter = getTauri()?.window?.getCurrentWindow;
|
||||
if (typeof getter !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getter() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -22,8 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.7",
|
||||
"@getpaseo/server": "0.1.7",
|
||||
"@getpaseo/relay": "0.1.14",
|
||||
"@getpaseo/server": "0.1.14",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user