mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 | ||
|
|
1d3e551e7f | ||
|
|
79795b6f49 | ||
|
|
16da3300e3 | ||
|
|
69d8427bd9 | ||
|
|
d8ccbd5c32 | ||
|
|
be93f8b240 | ||
|
|
b09a731d85 | ||
|
|
554017b0b8 | ||
|
|
9f089be946 | ||
|
|
213f155c9c | ||
|
|
9e6b45e2f0 | ||
|
|
355e56db53 | ||
|
|
a79134ec9e | ||
|
|
91dde29146 | ||
|
|
586b48e150 | ||
|
|
aeefa22ddf | ||
|
|
9f3ef07322 | ||
|
|
e2cb67462d | ||
|
|
b60d253926 | ||
|
|
ee156adffb | ||
|
|
c99b78f5b0 | ||
|
|
4c6d21af4a | ||
|
|
327b315610 | ||
|
|
78849fa0bf | ||
|
|
e5014a5f57 | ||
|
|
5bf698ff84 | ||
|
|
eb5f011161 | ||
|
|
acfb933ee8 | ||
|
|
c37684b246 | ||
|
|
e2068e3d72 | ||
|
|
443eb16e67 | ||
|
|
240dc26013 | ||
|
|
6b07555a46 | ||
|
|
b69bd5271b | ||
|
|
cf4cae2c7d | ||
|
|
d51f18a2f7 | ||
|
|
006db65f08 | ||
|
|
f21221c1e1 | ||
|
|
9faa88e13b | ||
|
|
faf1eed0ab |
188
.github/workflows/desktop-release.yml
vendored
188
.github/workflows/desktop-release.yml
vendored
@@ -59,13 +59,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -172,6 +170,7 @@ jobs:
|
||||
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: tauri_build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -192,6 +191,44 @@ jobs:
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Notarize and re-upload DMG
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
||||
if [ -z "$dmg_path" ]; then
|
||||
echo "::error::No DMG found in tauri build artifacts"
|
||||
exit 1
|
||||
fi
|
||||
echo "DMG: $dmg_path"
|
||||
|
||||
echo "Signing DMG..."
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
||||
|
||||
echo "Submitting DMG for notarization..."
|
||||
xcrun notarytool submit "$dmg_path" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$dmg_path"
|
||||
|
||||
echo "Verifying..."
|
||||
spctl --assess --type install --verbose "$dmg_path"
|
||||
|
||||
echo "Replacing release asset with notarized DMG..."
|
||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build macOS app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
||||
@@ -201,7 +238,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -214,13 +251,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -267,7 +302,7 @@ jobs:
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -303,6 +338,19 @@ jobs:
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Strip CUDA dependencies from onnxruntime
|
||||
shell: bash
|
||||
run: |
|
||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
||||
echo "Removing needed $lib from $f"
|
||||
patchelf --remove-needed "$lib" "$f"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -317,21 +365,87 @@ jobs:
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Linux Tauri release
|
||||
- name: Build Linux Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
id: linux_tauri
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles appimage
|
||||
NO_STRIP: "1"
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
|
||||
|
||||
- name: Attempt manual Linux AppImage fallback
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appdir_path="$appimage_dir/Paseo.AppDir"
|
||||
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
|
||||
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -n "$existing_appimage" ]; then
|
||||
if [ "$existing_appimage" != "$canonical_appimage" ]; then
|
||||
mv "$existing_appimage" "$canonical_appimage"
|
||||
fi
|
||||
if [ ! -f "$canonical_appimage.sig" ]; then
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d "$appdir_path" ]; then
|
||||
echo "::error::AppDir was not generated at $appdir_path"
|
||||
exit 1
|
||||
fi
|
||||
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
|
||||
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
|
||||
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
|
||||
- name: Fail Linux release when AppImage bundling fails
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::Linux AppImage assets are still missing after the manual fallback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux release assets
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::No Linux AppImage assets were produced."
|
||||
exit 1
|
||||
fi
|
||||
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build Linux app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
@@ -355,13 +469,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -431,7 +543,11 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
@@ -467,7 +583,7 @@ jobs:
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles nsis,msi
|
||||
args: --bundles nsis
|
||||
|
||||
- name: Build Windows app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -71,6 +71,8 @@ valknut-report.json/
|
||||
**/.paseo-provider-history/
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||
- Added Grep tool support in Claude tool-call mapping.
|
||||
- Added ability to open workspace files directly from agent chat messages.
|
||||
- Added desktop notifications via a custom native bridge.
|
||||
|
||||
### Improved
|
||||
- Improved image picker, markdown rendering, and UI interactions.
|
||||
- Improved shell environment detection using shell-env.
|
||||
|
||||
### Fixed
|
||||
- Fixed platform-specific markdown link rendering.
|
||||
- Fixed Linux AppImage CLI resource paths.
|
||||
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||
|
||||
## 0.1.26 - 2026-03-12
|
||||
|
||||
### Added
|
||||
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
|
||||
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
|
||||
- Added Windows support with improved cross-platform shell execution.
|
||||
|
||||
### Improved
|
||||
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
|
||||
- Added a Discord link to the website navigation.
|
||||
|
||||
### Fixed
|
||||
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
|
||||
- Fixed the home route to hide browser chrome when appropriate.
|
||||
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
|
||||
- Fixed noisy shell output interfering with executable lookup.
|
||||
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
|
||||
|
||||
## 0.1.25 - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
|
||||
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
|
||||
|
||||
## 0.1.24 - 2026-03-10
|
||||
|
||||
### Improved
|
||||
|
||||
281
CLAUDE.md
281
CLAUDE.md
@@ -1,267 +1,54 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
|
||||
|
||||
**Key features:**
|
||||
- Real-time streaming of agent output
|
||||
- Voice commands for hands-free interaction
|
||||
- Push notifications when tasks complete
|
||||
- Multi-agent orchestration across projects
|
||||
|
||||
**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Monorepo Structure
|
||||
## Repository map
|
||||
|
||||
This is an npm workspace monorepo:
|
||||
|
||||
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
|
||||
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
|
||||
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
|
||||
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
|
||||
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
|
||||
- `packages/app` — Mobile + web client (Expo)
|
||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||
- `packages/relay` — E2E encrypted relay for remote access
|
||||
- `packages/desktop` — Tauri desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Development Server
|
||||
## Documentation
|
||||
|
||||
The `npm run dev` script automatically picks an available port for the development server.
|
||||
| Doc | What's in it |
|
||||
|---|---|
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
- `PASEO_HOME` – path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
|
||||
|
||||
For trace+ logs, check $PASEO_HOME/daemon.log
|
||||
|
||||
## Running and checking logs
|
||||
|
||||
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Daemon and CLI
|
||||
|
||||
The Paseo daemon communicates via WebSocket. In the main checkout:
|
||||
- Daemon runs at `localhost:6767`
|
||||
- Expo app at `localhost:8081`
|
||||
- State lives in `$PASEO_HOME`
|
||||
|
||||
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
|
||||
|
||||
### Relay build sync (important)
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`.
|
||||
|
||||
### Server build sync for CLI (important)
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts.
|
||||
|
||||
### Quick reference CLI commands
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
```
|
||||
|
||||
### Agent state
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
Agent data is stored at:
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
## Critical rules
|
||||
|
||||
To find an agent by ID:
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Always run typecheck after every change.**
|
||||
|
||||
To find an agent by title or other content:
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
rg -l "spiteful-toad" $PASEO_HOME/agents/
|
||||
```
|
||||
## Orchestrator mode
|
||||
|
||||
### Provider session files
|
||||
|
||||
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
|
||||
|
||||
**Claude sessions:**
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex sessions:**
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Android
|
||||
|
||||
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
|
||||
|
||||
### Android variants (vanilla Expo)
|
||||
|
||||
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
|
||||
|
||||
- `production` -> app name `Paseo`, package `sh.paseo`
|
||||
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
|
||||
|
||||
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
### Local build + install (Android device)
|
||||
|
||||
From `packages/app`:
|
||||
|
||||
```bash
|
||||
# development (debug)
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# production (release)
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# clean native project (when needed)
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development
|
||||
npm run android:production
|
||||
npm run android:clean
|
||||
```
|
||||
|
||||
`npm run android:release` is an alias for `npm run android:production`.
|
||||
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
- Build Android with the `production` profile
|
||||
- Submit each build with the `production` submit profile
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
# List recent mobile workflow runs
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect one run (jobs, status, outputs)
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for all steps in one failed job
|
||||
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||
|
||||
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
|
||||
|
||||
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||
|
||||
## Release playbook
|
||||
|
||||
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
|
||||
|
||||
```bash
|
||||
# Recommended: full patch release (bump, check, publish, push branch+tag)
|
||||
npm run release:patch
|
||||
|
||||
# Manual, step-by-step fallback:
|
||||
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 + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
### Draft release flow
|
||||
|
||||
```bash
|
||||
# Stage a draft GitHub release with assets, but do not publish npm yet.
|
||||
npm run draft-release:patch
|
||||
|
||||
# Publish npm and promote the same GitHub draft release to final.
|
||||
npm run release:finalize
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `draft-release:patch` bumps the version, runs release checks, pushes `HEAD` and the new `v*` tag, and creates the matching GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to that same draft release.
|
||||
- `release:finalize` requires that the current tag already has a GitHub draft release, publishes the npm packages for that exact version, and promotes the same GitHub Release from draft to published.
|
||||
- Use the same semver tag for both draft and final states; do not cut a second tag just to publish the release.
|
||||
|
||||
Notes:
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed. The notes are user-facing:
|
||||
- Ask yourself, what do Paseo users want to know about?
|
||||
- Include: New features, bug fixes
|
||||
- Don't include: Refactors or code changes that are not noticeable by users
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- GitHub `Android APK Release` workflow for the same tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
|
||||
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
|
||||
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
|
||||
|
||||
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
|
||||
|
||||
## Agent Authentication
|
||||
|
||||
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
|
||||
|
||||
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
|
||||
|
||||
## NEVER DO THESE THINGS
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
|
||||
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
|
||||
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
|
||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
||||
- Launch agents in the most permissive mode
|
||||
- Set cwd to the repository root
|
||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
||||
|
||||
67
docs/ANDROID.md
Normal file
67
docs/ANDROID.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Android
|
||||
|
||||
## App variants
|
||||
|
||||
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
|
||||
|
||||
| Variant | App name | Package ID |
|
||||
|---|---|---|
|
||||
| `production` | Paseo | `sh.paseo` |
|
||||
| `development` | Paseo Debug | `sh.paseo.debug` |
|
||||
|
||||
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
## Local build + install
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clean # Clean native project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clean
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
```bash
|
||||
adb exec-out screencap -p > screenshot.png
|
||||
```
|
||||
|
||||
## Cloud build + submit (EAS)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# List recent workflow runs
|
||||
npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect a run
|
||||
npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for a failed job
|
||||
npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
184
docs/ARCHITECTURE.md
Normal file
184
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Architecture
|
||||
|
||||
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
│ (direct or │ (direct) │ + WebSocket
|
||||
│ via relay) │ │
|
||||
└───────────┬───────┴──────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Daemon │
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### `packages/server` — The daemon
|
||||
|
||||
The heart of Paseo. A Node.js process that:
|
||||
|
||||
- Listens for WebSocket connections from clients
|
||||
- Manages agent lifecycle (create, run, stop, resume, archive)
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
||||
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
||||
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands:
|
||||
|
||||
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree ls/archive`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Tauri)
|
||||
|
||||
Tauri wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
|
||||
**Binary multiplexing:**
|
||||
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
|---|---|---|
|
||||
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode CLI | Provider-managed |
|
||||
|
||||
All providers:
|
||||
- Handle their own authentication (Paseo does not manage API keys)
|
||||
- Support session resume via persistence handles
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
2. Session routes to `AgentManager.create()`
|
||||
3. AgentManager creates a `ManagedAgent`, initializes provider session
|
||||
4. Provider runs the agent → emits `AgentStreamEvent` items
|
||||
5. Events append to the agent timeline, broadcast to all subscribed clients
|
||||
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
|
||||
7. Permission requests flow: agent → server → client → user decision → server → agent
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
└── daemon.log # Daemon trace logs
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
174
docs/CODING_STANDARDS.md
Normal file
174
docs/CODING_STANDARDS.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Coding Standards
|
||||
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — justify every abstraction with specific benefits
|
||||
- **Fully typed TypeScript** — no `any`, no untyped boundaries
|
||||
- **YAGNI** — build features and abstractions only when needed
|
||||
- **Functional and declarative** over object-oriented
|
||||
- **`interface`** over `type` when possible
|
||||
- **`function` declarations** over arrow function assignments
|
||||
- **Single-purpose functions** — one function, one job
|
||||
- **Design for edge cases through types** rather than explicit handling
|
||||
- **Don't catch errors** unless there's a strong reason to
|
||||
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
|
||||
- **No "while I'm at it" improvements** — stay focused on the task
|
||||
|
||||
## Type hygiene
|
||||
|
||||
### Infer from schemas
|
||||
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicate type that can drift
|
||||
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
|
||||
type RPCArgs = { procedure: string; args: Record<string, unknown> };
|
||||
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
|
||||
### Named types over inline
|
||||
|
||||
No complex inline types in public function signatures.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
|
||||
### Object parameters
|
||||
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
|
||||
### One canonical type per concept
|
||||
|
||||
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicated fields across layers
|
||||
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
|
||||
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
|
||||
|
||||
// Good: canonical type + wrappers
|
||||
type ToolCall = { toolName: string; args: Record<string, unknown> };
|
||||
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
|
||||
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
|
||||
```
|
||||
|
||||
## Make impossible states impossible
|
||||
|
||||
Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "success"; data: Data };
|
||||
```
|
||||
|
||||
## Optionality is a design decision
|
||||
|
||||
Don't mark fields optional to avoid migrations. Decide deliberately:
|
||||
|
||||
1. Is optionality actually needed?
|
||||
2. If there are distinct valid states → discriminated union
|
||||
3. If value can be intentionally empty → explicit `null`
|
||||
4. Keep optionality at real boundaries (external input), then resolve it
|
||||
|
||||
## Validate at boundaries, trust internally
|
||||
|
||||
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
|
||||
|
||||
```typescript
|
||||
// Bad: optional chaining because shape is unclear
|
||||
const value = response?.data?.items?.[0]?.name;
|
||||
|
||||
// Good: validate at boundary, trust the types
|
||||
const parsed = responseSchema.parse(rawResponse);
|
||||
const value = parsed.data.items[0].name;
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
|
||||
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
|
||||
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
|
||||
|
||||
```typescript
|
||||
class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly operation: string,
|
||||
public readonly waitedMs: number,
|
||||
) {
|
||||
super(`${operation} timed out after ${waitedMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep logic density low
|
||||
|
||||
Avoid packing branching, lookup, and transformation into single dense expressions.
|
||||
|
||||
```typescript
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(account, rates.find((r) => r.region === account.region));
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
if (!rate) throw new MissingRateError(account.region);
|
||||
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
|
||||
```
|
||||
|
||||
## Centralize policy
|
||||
|
||||
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
|
||||
|
||||
## React: keep components dumb
|
||||
|
||||
- Components render state and dispatch events — they don't compute transitions
|
||||
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
|
||||
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
|
||||
- Never mirror a source of truth into local state; derive from it
|
||||
- Test state logic as pure functions without rendering
|
||||
|
||||
## File organization
|
||||
|
||||
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
|
||||
- Name files after the main export (`create-toolcall.ts`)
|
||||
- Use `index.ts` as an entrypoint, not a dumping ground
|
||||
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
|
||||
|
||||
## Refactoring contract
|
||||
|
||||
Refactoring is structure work, not feature work.
|
||||
|
||||
- Preserve behavior by default, especially user-facing behavior
|
||||
- Do not remove features to simplify code without explicit approval
|
||||
- Have a verification strategy before you start
|
||||
- Fully migrate callers and remove old paths in the same refactor
|
||||
- No fallback behavior by default — prefer explicit error over silent degradation
|
||||
73
docs/DESIGN.md
Normal file
73
docs/DESIGN.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Designing Features
|
||||
|
||||
How to think through a feature before writing code.
|
||||
|
||||
## Start from the user
|
||||
|
||||
Even for backend work, start from the user's perspective:
|
||||
|
||||
- What problem does this solve?
|
||||
- What triggers it? User action, schedule, event?
|
||||
- What does success look like from the user's perspective?
|
||||
- What data does it need? Where does that data come from?
|
||||
|
||||
## Map existing code
|
||||
|
||||
Before designing anything new, understand what exists:
|
||||
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
|
||||
- What types and data shapes are already defined?
|
||||
|
||||
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
|
||||
|
||||
## Define verification before implementation
|
||||
|
||||
Before designing the solution, define how you'll know it works:
|
||||
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
|
||||
## Design the shape
|
||||
|
||||
### Data
|
||||
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
|
||||
|
||||
### Layers
|
||||
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
|
||||
### Interactions
|
||||
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
|
||||
### Refactoring
|
||||
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
|
||||
## Create a concrete plan
|
||||
|
||||
Once the design is clear:
|
||||
|
||||
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
|
||||
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
|
||||
3. **What to refactor** before adding new code
|
||||
4. **How to verify** each step
|
||||
|
||||
## Principles
|
||||
|
||||
- **Fit, don't force** — new code should fit existing patterns, or refactor first
|
||||
- **Simple** — the best design is the simplest one that works
|
||||
- **Verify early** — define how to test before designing the implementation
|
||||
130
docs/DEVELOPMENT.md
Normal file
130
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (see `.tool-versions` for exact version)
|
||||
- npm workspaces (comes with Node)
|
||||
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
|
||||
|
||||
### Running alongside the main checkout
|
||||
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||
|
||||
### Default ports
|
||||
|
||||
In the main checkout:
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
|
||||
### Server → CLI
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Find an agent by ID:
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
|
||||
Find by content:
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
```
|
||||
|
||||
## Provider session files
|
||||
|
||||
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex:**
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
npx expo-doctor
|
||||
```
|
||||
|
||||
Diagnoses version mismatches and native module issues.
|
||||
|
||||
## Typecheck
|
||||
|
||||
Always run typecheck after changes:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
48
docs/RELEASE.md
Normal file
48
docs/RELEASE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Release
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:check # Validate release
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Draft release flow
|
||||
|
||||
```bash
|
||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||
npm run release:finalize # Publish npm, promote draft to published
|
||||
```
|
||||
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] `npm run release:patch` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `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
|
||||
123
docs/TESTING.md
Normal file
123
docs/TESTING.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Testing
|
||||
|
||||
## Philosophy
|
||||
|
||||
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
|
||||
|
||||
## Test-driven development
|
||||
|
||||
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
|
||||
|
||||
```
|
||||
RIGHT (vertical):
|
||||
RED→GREEN: test1→impl1
|
||||
RED→GREEN: test2→impl2
|
||||
RED→GREEN: test3→impl3
|
||||
|
||||
WRONG (horizontal):
|
||||
RED: test1, test2, test3, test4, test5
|
||||
GREEN: impl1, impl2, impl3, impl4, impl5
|
||||
```
|
||||
|
||||
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
|
||||
|
||||
## Determinism first
|
||||
|
||||
Tests must produce the same result every run:
|
||||
|
||||
- No conditional assertions or branching paths
|
||||
- No reliance on timing, randomness, or network jitter
|
||||
- No weak assertions (`toBeTruthy`, `toBeDefined`)
|
||||
- Assert the full intended behavior, not fragments
|
||||
|
||||
```typescript
|
||||
// Bad: conditional and weak
|
||||
it("creates a tool call", async () => {
|
||||
const result = await createToolCall(input);
|
||||
if (result.ok) {
|
||||
expect(result.id).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
// Good: deterministic and explicit
|
||||
it("returns timeout error when provider times out", async () => {
|
||||
const result = await createToolCall(input);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Flaky tests are a bug
|
||||
|
||||
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
|
||||
|
||||
## Real dependencies over mocks
|
||||
|
||||
Mocks are not the default. They require an explicit decision.
|
||||
|
||||
- **Database**: real test database, not a mock
|
||||
- **APIs**: real APIs with test/sandbox credentials, not request mocks
|
||||
- **File system**: temporary directory that gets cleaned up, not fs mocks
|
||||
|
||||
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
|
||||
|
||||
### Use swappable adapters instead
|
||||
|
||||
When you need test isolation, design code so dependencies are injectable:
|
||||
|
||||
```typescript
|
||||
interface EmailSender {
|
||||
send(to: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Production
|
||||
const realSender: EmailSender = { send: sendgrid.send };
|
||||
|
||||
// Test: in-memory adapter
|
||||
function createTestEmailSender() {
|
||||
const sent: Array<{ to: string; body: string }> = [];
|
||||
return {
|
||||
send: async (to: string, body: string) => { sent.push({ to, body }); },
|
||||
sent,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## End-to-end means end-to-end
|
||||
|
||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
|
||||
|
||||
## Debugging with tests
|
||||
|
||||
Use the test as your debugging ground:
|
||||
|
||||
1. Add temporary logging to the code under test
|
||||
2. Run the test, observe actual values
|
||||
3. Trace the flow end-to-end through test output
|
||||
4. Confirm each assumption with actual output
|
||||
5. Remove logging when done
|
||||
|
||||
The test output is the source of truth, not your reading of the code.
|
||||
|
||||
## Design for testability
|
||||
|
||||
If code isn't testable, refactor it. Signs:
|
||||
- You want to reach for a mock
|
||||
- You can't inject a dependency
|
||||
- You need to test private internals
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
169
package-lock.json
generated
169
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -9138,6 +9138,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-log": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
|
||||
"integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
@@ -12704,6 +12713,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-shell": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
|
||||
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||
@@ -14331,6 +14352,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/execa/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -16971,6 +17021,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-symbol-description": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||
@@ -17555,6 +17617,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
@@ -18215,7 +18286,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -20656,7 +20726,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -21237,6 +21306,18 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -21455,7 +21536,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
@@ -24402,6 +24482,50 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz",
|
||||
"integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-shell": "^2.0.0",
|
||||
"execa": "^5.1.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
@@ -25141,6 +25265,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
||||
@@ -27561,7 +27694,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -27569,7 +27702,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.24",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -27589,6 +27722,7 @@
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
@@ -27707,11 +27841,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.24",
|
||||
"@getpaseo/server": "0.1.24",
|
||||
"@getpaseo/relay": "0.1.27",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -27748,14 +27882,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -27771,12 +27905,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.24",
|
||||
"@getpaseo/relay": "0.1.27",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -27804,8 +27938,9 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"sherpa-onnx": "^1.12.23",
|
||||
"sherpa-onnx-node": "^1.12.23",
|
||||
"shell-env": "^4.0.3",
|
||||
"sherpa-onnx": "1.12.28",
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -28132,7 +28267,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
|
||||
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
Binary file not shown.
@@ -175,29 +175,6 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const diagnostics = await page.evaluate(async () => {
|
||||
const debug = (
|
||||
window as {
|
||||
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
|
||||
consumeReports?: () => Promise<unknown[]>;
|
||||
};
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS_DEBUG__;
|
||||
if (!debug || typeof debug.consumeReports !== "function") {
|
||||
return { available: false, reports: [] as unknown[] };
|
||||
}
|
||||
try {
|
||||
const reports = await debug.consumeReports();
|
||||
return { available: true, reports: Array.isArray(reports) ? reports : [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: true,
|
||||
reports: [] as unknown[],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
@@ -220,12 +197,6 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
diagnostics: {
|
||||
available: diagnostics.available,
|
||||
reportCount: diagnostics.reports.length,
|
||||
reports: diagnostics.reports,
|
||||
error: "error" in diagnostics ? diagnostics.error : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
|
||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
||||
attachConsole();
|
||||
});
|
||||
}
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
import "expo-router/entry";
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
@@ -14,6 +15,11 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
const escapedAppSrcRoot = appSrcRoot
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
.join("[\\\\/]");
|
||||
const pathSeparatorPattern = "[\\\\/]";
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
...(config.resolver.extraNodeModules ?? {}),
|
||||
@@ -22,6 +28,9 @@ config.resolver.extraNodeModules = {
|
||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||
};
|
||||
config.resolver.blockList = new RegExp(
|
||||
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
|
||||
);
|
||||
|
||||
function isLocalModuleImport(moduleName) {
|
||||
return (
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -33,7 +33,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.24",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
|
||||
@@ -12,10 +12,24 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, ActivityIndicator, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHosts,
|
||||
useHostMutations,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
@@ -36,6 +50,7 @@ import { getIsTauri } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -50,20 +65,11 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
||||
|
||||
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);
|
||||
}
|
||||
attachConsole();
|
||||
const HostRuntimeBootstrapContext = createContext(false);
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
@@ -72,20 +78,39 @@ 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"
|
||||
);
|
||||
});
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
void listenToDesktopNotificationClicks((payload) => {
|
||||
router.push(buildNotificationRoute(payload.data) as any);
|
||||
})
|
||||
.then((cleanup) => {
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[OSNotifications][Desktop] Failed to register notification click listener",
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
||||
event.preventDefault();
|
||||
router.push(route as any);
|
||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
@@ -141,6 +166,79 @@ function PushNotificationRouter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={daemon.serverId}
|
||||
serverId={daemon.serverId}
|
||||
client={client}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HostSessionManager() {
|
||||
const hosts = useHosts();
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hosts.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
||||
{children}
|
||||
</HostRuntimeBootstrapContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useStoreReady(): boolean {
|
||||
return useContext(HostRuntimeBootstrapContext);
|
||||
}
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -148,11 +246,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
function AppContainer({
|
||||
children,
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
@@ -162,7 +265,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const chromeEnabled = daemons.length > 0;
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const isOpen = chromeEnabled
|
||||
? isMobile
|
||||
? mobileView === "agent-list"
|
||||
@@ -224,10 +327,6 @@ 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
|
||||
@@ -244,13 +343,6 @@ 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)();
|
||||
@@ -305,8 +397,9 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
const storeReady = useStoreReady();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const isLoading = settingsLoading || !storeReady;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
@@ -325,7 +418,8 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
);
|
||||
@@ -375,6 +469,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -393,7 +488,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
}, [params.open, pathname]);
|
||||
|
||||
return (
|
||||
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
|
||||
<AppContainer
|
||||
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
||||
chromeEnabled={shouldShowAppChrome}
|
||||
>
|
||||
{children}
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -453,55 +553,52 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<PerfDiagnosticsProvider scope="root_layout">
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<PushNotificationRouter />
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</DaemonRegistryProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</PerfDiagnosticsProvider>
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
36
packages/app/src/app/index-startup.ts
Normal file
36
packages/app/src/app/index-startup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const WELCOME_ROUTE = '/welcome'
|
||||
|
||||
export function shouldWaitOnStartupRace(input: {
|
||||
onlineServerId: string | null
|
||||
hasTimedOut: boolean
|
||||
isDesktopStartupRace: boolean
|
||||
daemonCount: number
|
||||
pathname: string
|
||||
}): boolean {
|
||||
if (input.onlineServerId) {
|
||||
return false
|
||||
}
|
||||
if (input.pathname === WELCOME_ROUTE) {
|
||||
return false
|
||||
}
|
||||
if (input.hasTimedOut) {
|
||||
return false
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
||||
}
|
||||
|
||||
export function shouldRedirectToWelcome(input: {
|
||||
onlineServerId: string | null
|
||||
hasTimedOut: boolean
|
||||
pathname: string
|
||||
isDesktopStartupRace: boolean
|
||||
daemonCount: number
|
||||
}): boolean {
|
||||
if (input.onlineServerId || !input.hasTimedOut) {
|
||||
return false
|
||||
}
|
||||
if (input.pathname !== '/' && input.pathname !== '') {
|
||||
return false
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
||||
}
|
||||
@@ -1,65 +1,113 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'expo-router'
|
||||
import { useHosts } from '@/runtime/host-runtime'
|
||||
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from './index-startup'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore()
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null
|
||||
let firstOnlineAt: string | null = null
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt
|
||||
firstOnlineServerId = serverId
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId
|
||||
},
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null
|
||||
let firstOnlineAt: string | null = null
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt
|
||||
firstOnlineServerId = serverId
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const requestedServerId = useMemo(() => {
|
||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
}, [params.serverId]);
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const daemons = useHosts()
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setHasTimedOut(true)
|
||||
}, STARTUP_TIMEOUT_MS)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (requestedServerId) {
|
||||
const requested = daemons.find(
|
||||
(daemon) => daemon.serverId === requestedServerId
|
||||
);
|
||||
if (requested) {
|
||||
return requested.serverId;
|
||||
}
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId, requestedServerId]);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
if (!onlineServerId) {
|
||||
return
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
if (pathname !== '/' && pathname !== '') {
|
||||
return
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(targetServerId) as any);
|
||||
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any)
|
||||
}, [onlineServerId, pathname, router])
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return <StartupSplashScreen />;
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRedirectToWelcome({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
pathname,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
router.replace(WELCOME_ROUTE as any)
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
if (isReconciling) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
return <WelcomeScreen />;
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult } from "expo-camera";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -151,7 +151,8 @@ export default function PairScanScreen() {
|
||||
const sourceServerId =
|
||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
@@ -241,7 +242,7 @@ export default function PairScanScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
await probeConnection(
|
||||
const { client } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
@@ -250,6 +251,7 @@ export default function PairScanScreen() {
|
||||
},
|
||||
{ serverId: offer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||
@@ -311,7 +313,7 @@ export default function PairScanScreen() {
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
void updateHost(serverId, { label }).finally(() => {
|
||||
void renameHost(serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
});
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
@@ -29,16 +29,16 @@ export default function LegacySettingsRoute() {
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
if (preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
}, [preferencesLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
if (preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
5
packages/app/src/app/welcome.tsx
Normal file
5
packages/app/src/app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Link2 } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError, probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -129,7 +130,8 @@ export interface AddHostModalProps {
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, upsertDirectConnection } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { upsertDirectConnection } = useHostMutations();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
@@ -179,11 +181,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { serverId, hostname } = await probeConnection({
|
||||
const { client, serverId, hostname } = await connectToDaemon({
|
||||
id: "probe",
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
});
|
||||
await client.close().catch(() => undefined);
|
||||
if (targetServerId && serverId !== targetServerId) {
|
||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||
setErrorMessage(message);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||
import {
|
||||
deleteAttachments,
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from '@/attachments/service'
|
||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||
@@ -390,16 +391,24 @@ export function AgentInputArea({
|
||||
|
||||
async function handlePickImage() {
|
||||
const result = await pickImages()
|
||||
if (!result?.assets?.length) {
|
||||
if (!result?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const newImages = await Promise.all(
|
||||
result.assets.map(async (asset) => {
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === 'blob') {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
fileName: asset.fileName ?? null,
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ import { shortenPath } from '@/utils/shorten-path'
|
||||
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { AgentStatusDot } from '@/components/agent-status-dot'
|
||||
import { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing'
|
||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -271,13 +270,6 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId)
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: 'home',
|
||||
to: 'agent',
|
||||
params: { serverId, agentId },
|
||||
})
|
||||
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
|
||||
@@ -67,17 +67,12 @@ import {
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
@@ -91,6 +86,7 @@ export interface AgentStreamViewProps {
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||
@@ -101,6 +97,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
onOpenWorkspaceFile,
|
||||
}, ref) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -116,7 +113,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
[isMobile]
|
||||
);
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -165,6 +161,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({ filePath: normalized.file });
|
||||
return;
|
||||
}
|
||||
|
||||
const route = buildHostWorkspaceFileRoute(
|
||||
resolvedServerId,
|
||||
workspaceId,
|
||||
@@ -194,6 +195,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
@@ -462,74 +464,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
[pendingPermissions, agentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const totalCount = streamItems.length;
|
||||
const prevCount = streamItemCountRef.current;
|
||||
if (totalCount === prevCount) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.abs(totalCount - prevCount);
|
||||
streamItemCountRef.current = totalCount;
|
||||
if (
|
||||
totalCount < STREAM_ITEM_LOG_MIN_COUNT &&
|
||||
delta < STREAM_ITEM_LOG_DELTA_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let userCount = 0;
|
||||
let assistantCount = 0;
|
||||
let toolCallCount = 0;
|
||||
let thoughtCount = 0;
|
||||
let activityCount = 0;
|
||||
let todoCount = 0;
|
||||
for (const item of streamItems) {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
userCount += 1;
|
||||
break;
|
||||
case "assistant_message":
|
||||
assistantCount += 1;
|
||||
break;
|
||||
case "tool_call":
|
||||
toolCallCount += 1;
|
||||
break;
|
||||
case "thought":
|
||||
thoughtCount += 1;
|
||||
break;
|
||||
case "activity_log":
|
||||
activityCount += 1;
|
||||
break;
|
||||
case "todo_list":
|
||||
todoCount += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const metrics =
|
||||
totalCount >= STREAM_ITEM_LOG_MIN_COUNT
|
||||
? measurePayload(streamItems)
|
||||
: null;
|
||||
perfLog(AGENT_STREAM_LOG_TAG, {
|
||||
event: "stream_items",
|
||||
agentId,
|
||||
totalCount,
|
||||
userCount,
|
||||
assistantCount,
|
||||
toolCallCount,
|
||||
thoughtCount,
|
||||
activityCount,
|
||||
todoCount,
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
streamHeadCount: streamHead?.length ?? 0,
|
||||
payloadApproxBytes: metrics?.approxBytes ?? 0,
|
||||
payloadFieldCount: metrics?.fieldCount ?? 0,
|
||||
});
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
const pendingPermissionsNode =
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
@@ -154,16 +153,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
useEffect(() => {
|
||||
if (!artifact) return;
|
||||
console.log(
|
||||
"[ArtifactDrawer] Showing artifact:",
|
||||
artifact.id,
|
||||
artifact.type,
|
||||
artifact.title
|
||||
);
|
||||
}, [artifact]);
|
||||
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
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 logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
|
||||
}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
|
||||
@@ -41,7 +41,7 @@ import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import {
|
||||
@@ -105,7 +105,7 @@ export function FileExplorerPane({
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId]
|
||||
@@ -838,7 +838,7 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
desktopSplit: {
|
||||
flex: 1,
|
||||
@@ -879,7 +879,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
paneHeaderLeft: {
|
||||
flex: 1,
|
||||
|
||||
@@ -38,7 +38,6 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -66,11 +65,6 @@ function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
|
||||
const DIFF_FILE_LOG_LINE_THRESHOLD = 500;
|
||||
const DIFF_FILE_LOG_TOKEN_THRESHOLD = 5000;
|
||||
|
||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
interface HighlightedTextProps {
|
||||
@@ -199,75 +193,14 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
}: DiffFileSectionProps) {
|
||||
const expandStartRef = useRef<number | null>(null);
|
||||
const layoutYRef = useRef<number | null>(null);
|
||||
const pressHandledRef = useRef(false);
|
||||
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
|
||||
|
||||
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
|
||||
let totalLines = 0;
|
||||
let totalTokens = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
totalLines += hunk.lines.length;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.tokens) {
|
||||
totalTokens += line.tokens.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
hunkCount: file.hunks.length,
|
||||
lineCount: totalLines,
|
||||
tokenCount: totalTokens,
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
const shouldLogFileMetrics =
|
||||
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
|
||||
tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD;
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
pressHandledRef.current = true;
|
||||
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
|
||||
expandStartRef.current = getNowMs();
|
||||
perfLog(DIFF_FILE_LOG_TAG, {
|
||||
event: "toggle",
|
||||
path: file.path,
|
||||
nextExpanded: !isExpanded,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
});
|
||||
}
|
||||
onToggle(file.path);
|
||||
}, [file.path, onToggle, isExpanded, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled() || !shouldLogFileMetrics) {
|
||||
return;
|
||||
}
|
||||
const startMs = expandStartRef.current;
|
||||
if (startMs === null) {
|
||||
return;
|
||||
}
|
||||
expandStartRef.current = null;
|
||||
const logCommit = () => {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
perfLog(DIFF_FILE_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
path: file.path,
|
||||
durationMs: Math.round(durationMs),
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
});
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => logCommit());
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
|
||||
}, [file.path, onToggle]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -506,30 +439,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
const diffMetrics = useMemo(() => {
|
||||
let hunkCount = 0;
|
||||
let lineCount = 0;
|
||||
let tokenCount = 0;
|
||||
for (const file of files) {
|
||||
hunkCount += file.hunks.length;
|
||||
for (const hunk of file.hunks) {
|
||||
lineCount += hunk.lines.length;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.tokens) {
|
||||
tokenCount += line.tokens.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
fileCount: files.length,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
};
|
||||
}, [files]);
|
||||
const lastMetricsKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
void refreshDiff();
|
||||
@@ -712,28 +621,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
setDiffModeOverride(null);
|
||||
}, [autoDiffMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const metricsKey = `${diffMetrics.fileCount}:${diffMetrics.hunkCount}:${diffMetrics.lineCount}:${diffMetrics.tokenCount}`;
|
||||
if (lastMetricsKeyRef.current === metricsKey) {
|
||||
return;
|
||||
}
|
||||
lastMetricsKeyRef.current = metricsKey;
|
||||
perfLog(DIFF_PANE_LOG_TAG, {
|
||||
event: "files_snapshot",
|
||||
serverId,
|
||||
workspaceId: workspaceId ?? cwd,
|
||||
fileCount: diffMetrics.fileCount,
|
||||
hunkCount: diffMetrics.hunkCount,
|
||||
lineCount: diffMetrics.lineCount,
|
||||
tokenCount: diffMetrics.tokenCount,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
});
|
||||
}, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
|
||||
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
);
|
||||
|
||||
@@ -15,12 +15,12 @@ import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
||||
import { formatConnectionStatus } from '@/utils/daemons'
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
||||
import {
|
||||
@@ -32,14 +32,6 @@ import {
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return
|
||||
}
|
||||
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string
|
||||
@@ -53,7 +45,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent)
|
||||
const pathname = usePathname()
|
||||
const { daemons } = useDaemonRegistry()
|
||||
const daemons = useHosts()
|
||||
const runtime = getHostRuntimeStore()
|
||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
@@ -125,6 +117,8 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
})
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects)
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -254,7 +248,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true
|
||||
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!isMobile) return
|
||||
@@ -272,11 +265,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
isGesturing.value = false
|
||||
if (!isMobile) return
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||
runOnJS(logLeftSidebarCloseGesture)('end', {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
})
|
||||
if (shouldClose) {
|
||||
animateToClose()
|
||||
runOnJS(handleClose)()
|
||||
@@ -352,8 +340,10 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -476,8 +466,10 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
|
||||
@@ -101,7 +101,6 @@ export interface MessageInputRef {
|
||||
const MIN_INPUT_HEIGHT = 30
|
||||
const MAX_INPUT_HEIGHT = 160
|
||||
const IS_WEB = Platform.OS === 'web'
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
@@ -125,13 +124,10 @@ type TextAreaHandle = {
|
||||
}
|
||||
|
||||
function logWebStickyBottom(
|
||||
event: string,
|
||||
details: Record<string, unknown>
|
||||
_event: string,
|
||||
_details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV || !IS_WEB) {
|
||||
return
|
||||
}
|
||||
console.log('[WebStickyBottom]', event, details)
|
||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||
}
|
||||
|
||||
function getDebugNow(): number | null {
|
||||
@@ -407,6 +403,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
|
||||
const showOverlay = showDictationOverlay || showRealtimeOverlay
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isDictating || isDictationProcessing) {
|
||||
return
|
||||
@@ -1070,10 +1067,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
/>
|
||||
) : showRealtimeOverlay && voice ? (
|
||||
<RealtimeVoiceOverlay
|
||||
volume={voice.volume}
|
||||
isMuted={voice.isMuted}
|
||||
isDetecting={voice.isDetecting}
|
||||
isSpeaking={voice.isSpeaking}
|
||||
isSwitching={voice.isVoiceSwitching}
|
||||
onToggleMute={voice.toggleMute}
|
||||
onStop={() => {
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
isValidElement,
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
@@ -68,8 +71,11 @@ import {
|
||||
buildToolCallDisplayModel,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import {
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
type InlinePathTarget,
|
||||
} from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
@@ -434,20 +440,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
// Used in custom markdownRules for inline code styling
|
||||
markdownCodeInline: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
markdownCodeInlineLink: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -464,6 +456,44 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function MarkdownLink({
|
||||
href,
|
||||
style,
|
||||
onPress,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
style: any;
|
||||
onPress: (url: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
if (Platform.OS !== "web") {
|
||||
return (
|
||||
<Text
|
||||
accessibilityRole="link"
|
||||
onPress={() => onPress(href)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => onPress(href)}
|
||||
onHoverIn={() => setHovered(true)}
|
||||
onHoverOut={() => setHovered(false)}
|
||||
>
|
||||
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string
|
||||
@@ -488,6 +518,19 @@ function getInlineCodeAutoLinkUrl(
|
||||
return match.url;
|
||||
}
|
||||
|
||||
function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
if (Array.isArray(parent)) {
|
||||
return parent.some((entry) => entry?.type === type);
|
||||
}
|
||||
|
||||
return (
|
||||
typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
"type" in parent &&
|
||||
(parent as { type?: string }).type === type
|
||||
);
|
||||
}
|
||||
|
||||
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
alignSelf: "flex-start",
|
||||
@@ -710,16 +753,35 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
|
||||
const markdownParser = useMemo(
|
||||
() => MarkdownIt({ typographer: true, linkify: true }),
|
||||
() => {
|
||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
parser.validateLink = (url: string) => {
|
||||
if (url.trim().toLowerCase().startsWith("file://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return defaultValidateLink(url);
|
||||
};
|
||||
return parser;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
const fileTarget = onInlinePathPress
|
||||
? parseFileProtocolUrl(url)
|
||||
: null;
|
||||
if (fileTarget) {
|
||||
onInlinePathPress?.(fileTarget);
|
||||
return false;
|
||||
}
|
||||
|
||||
void openExternalUrl(url);
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, []);
|
||||
}, [onInlinePathPress]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
@@ -776,12 +838,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
_styles: any,
|
||||
parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const parsed = onInlinePathPress
|
||||
const isLinkedInlineCode =
|
||||
nodeHasParentType(parent, "link") ||
|
||||
(!Array.isArray(parent) &&
|
||||
typeof parent?.attributes?.href === "string");
|
||||
const parsed = onInlinePathPress && !isLinkedInlineCode
|
||||
? parseInlinePathToken(content)
|
||||
: null;
|
||||
|
||||
@@ -804,30 +870,21 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||
if (inlineCodeLinkUrl) {
|
||||
return (
|
||||
<Text
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
accessibilityRole="link"
|
||||
onPress={() => {
|
||||
handleLinkPress(inlineCodeLinkUrl);
|
||||
}}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
assistantMessageStylesheet.markdownCodeInlineLink,
|
||||
]}
|
||||
href={inlineCodeLinkUrl}
|
||||
style={[inheritedStyles, styles.code_inline, styles.link]}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</MarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
]}
|
||||
style={[inheritedStyles, styles.code_inline]}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -878,6 +935,25 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
) => (
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
href={node.attributes?.href ?? ""}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
|
||||
: child
|
||||
)}
|
||||
</MarkdownLink>
|
||||
),
|
||||
};
|
||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||
|
||||
@@ -1786,10 +1862,6 @@ interface ToolCallProps {
|
||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
const TOOL_CALL_LOG_TAG = "[ToolCall]";
|
||||
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
||||
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
args,
|
||||
@@ -1806,7 +1878,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const toggleStartRef = useRef<number | null>(null);
|
||||
|
||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||
const isMobile =
|
||||
@@ -1849,22 +1920,20 @@ export const ToolCall = memo(function ToolCall({
|
||||
const displayName = displayModel.displayName;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const iconCategory = effectiveDetail?.type ?? toolName.trim().toLowerCase();
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
Boolean(error) ||
|
||||
(effectiveDetail
|
||||
? effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
? effectiveDetail.type === "plain_text"
|
||||
? Boolean(effectiveDetail.text)
|
||||
: effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
: false);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!isMobile && isPerfLoggingEnabled()) {
|
||||
toggleStartRef.current = getNowMs();
|
||||
}
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
@@ -1878,33 +1947,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const startMs = toggleStartRef.current;
|
||||
if (startMs === null) {
|
||||
return;
|
||||
}
|
||||
toggleStartRef.current = null;
|
||||
const logCommit = () => {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
if (durationMs >= TOOL_CALL_COMMIT_THRESHOLD_MS) {
|
||||
perfLog(TOOL_CALL_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
toolName,
|
||||
iconCategory,
|
||||
durationMs: Math.round(durationMs),
|
||||
});
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => logCommit());
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, isMobile, toolName, iconCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
return;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeSession,
|
||||
} from "@/runtime/host-runtime";
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={daemon.serverId}
|
||||
serverId={daemon.serverId}
|
||||
client={client}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiDaemonSessionHost() {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = getHostRuntimeStore();
|
||||
runtime.syncHosts(daemons);
|
||||
}, [daemons]);
|
||||
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{daemons.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Link } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -52,7 +53,8 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
@@ -122,7 +124,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const probeResult = await probeConnection(
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
@@ -131,10 +133,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
||||
},
|
||||
{ serverId: parsedOffer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost });
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
@@ -28,7 +27,7 @@ export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pathname = usePathname();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
@@ -2,13 +2,11 @@ import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
|
||||
interface RealtimeVoiceOverlayProps {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isDetecting: boolean;
|
||||
isSpeaking: boolean;
|
||||
isSwitching: boolean;
|
||||
onToggleMute: () => void;
|
||||
onStop: () => void;
|
||||
@@ -18,16 +16,13 @@ const OVERLAY_BUTTON_SIZE = 44;
|
||||
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
||||
|
||||
export function RealtimeVoiceOverlay({
|
||||
volume,
|
||||
isMuted,
|
||||
isDetecting,
|
||||
isSpeaking,
|
||||
isSwitching,
|
||||
onToggleMute,
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.meterContainer}>
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
import { SyncedLoader } from '@/components/synced-loader'
|
||||
import { useToast } from '@/contexts/toast-context'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
||||
import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
@@ -79,10 +78,17 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
return `data:${icon.mimeType};base64,${icon.data}`
|
||||
}
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) =>
|
||||
workspace.workspaceKey
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
isOpen?: boolean
|
||||
projects: SidebarProjectEntry[]
|
||||
serverId: string | null
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
onToggleProjectCollapsed: (projectKey: string) => void
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
isRefreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
onWorkspacePress?: () => void
|
||||
@@ -122,6 +128,7 @@ interface WorkspaceRowInnerProps {
|
||||
archiveStatus?: 'idle' | 'pending' | 'success'
|
||||
archivePendingLabel?: string
|
||||
onArchive?: () => void
|
||||
onCopyBranchName?: () => void
|
||||
onCopyPath?: () => void
|
||||
}
|
||||
|
||||
@@ -250,7 +257,6 @@ function NewWorktreeButton({
|
||||
function useLongPressDragInteraction(input: {
|
||||
drag: () => void
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
debugId: string
|
||||
}) {
|
||||
const didLongPressRef = useRef(false)
|
||||
const dragArmedRef = useRef(false)
|
||||
@@ -288,15 +294,11 @@ function useLongPressDragInteraction(input: {
|
||||
input.menuController.setOpen(true)
|
||||
menuOpenedRef.current = true
|
||||
didLongPressRef.current = true
|
||||
console.log('[sidebar-dnd-debug] context menu opened', { id: input.debugId })
|
||||
}, [input.debugId, input.menuController])
|
||||
}, [input.menuController])
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
console.log('[sidebar-dnd-debug] native onLongPress ignored (manual state machine active)', {
|
||||
id: input.debugId,
|
||||
})
|
||||
}, [input.debugId])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -325,16 +327,11 @@ function useLongPressDragInteraction(input: {
|
||||
const dy = current.y - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
console.log('[sidebar-dnd-debug] drag arm cancelled (movement)', {
|
||||
id: input.debugId,
|
||||
distance,
|
||||
})
|
||||
return
|
||||
}
|
||||
dragArmedRef.current = true
|
||||
dragActivatedRef.current = true
|
||||
didLongPressRef.current = true
|
||||
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
input.drag()
|
||||
}, DRAG_ARM_DELAY_MS)
|
||||
@@ -356,17 +353,12 @@ function useLongPressDragInteraction(input: {
|
||||
const dy = current.y - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
console.log('[sidebar-dnd-debug] context menu cancelled (movement)', {
|
||||
id: input.debugId,
|
||||
distance,
|
||||
})
|
||||
return
|
||||
}
|
||||
console.log('[sidebar-dnd-debug] long-press armed', { id: input.debugId })
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
openContextMenuAtStartPoint()
|
||||
}, CONTEXT_MENU_DELAY_MS)
|
||||
}, [clearTimers, input.debugId, input.menuController, openContextMenuAtStartPoint])
|
||||
}, [clearTimers, input.menuController, openContextMenuAtStartPoint])
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(details: { dx: number; dy: number; distance: number }) => {
|
||||
@@ -376,10 +368,9 @@ function useLongPressDragInteraction(input: {
|
||||
didStartDragRef.current = true
|
||||
didLongPressRef.current = true
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] drag movement detected', { id: input.debugId, ...details })
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
},
|
||||
[clearTimers, input.debugId]
|
||||
[clearTimers]
|
||||
)
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
@@ -387,18 +378,16 @@ function useLongPressDragInteraction(input: {
|
||||
scrollIntentRef.current = true
|
||||
didLongPressRef.current = true
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] scroll intent detected', { id: input.debugId, ...details })
|
||||
},
|
||||
[clearTimers, input.debugId]
|
||||
[clearTimers]
|
||||
)
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] swipe intent detected', { id: input.debugId, ...details })
|
||||
},
|
||||
[clearTimers, input.debugId]
|
||||
[clearTimers]
|
||||
)
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
@@ -416,13 +405,8 @@ function useLongPressDragInteraction(input: {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
}
|
||||
console.log('[sidebar-dnd-debug] press-in', {
|
||||
id: input.debugId,
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
})
|
||||
armTimers()
|
||||
}, [armTimers, input.debugId])
|
||||
}, [armTimers])
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: any) => {
|
||||
@@ -469,20 +453,11 @@ function useLongPressDragInteraction(input: {
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] press-out no context-menu', {
|
||||
id: input.debugId,
|
||||
didLongPress: didLongPressRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
dragActivated: dragActivatedRef.current,
|
||||
scrollIntent: scrollIntentRef.current,
|
||||
dragArmed: dragArmedRef.current,
|
||||
menuOpened: menuOpenedRef.current,
|
||||
})
|
||||
dragArmedRef.current = false
|
||||
dragActivatedRef.current = false
|
||||
touchStartRef.current = null
|
||||
touchCurrentRef.current = null
|
||||
}, [clearTimers, input.debugId])
|
||||
}, [clearTimers])
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
@@ -511,7 +486,6 @@ function ProjectHeaderRow({
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
debugId: `project:${project.projectKey}`,
|
||||
})
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -610,6 +584,7 @@ function WorkspaceRowInner({
|
||||
archiveStatus = 'idle',
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles()
|
||||
@@ -618,7 +593,6 @@ function WorkspaceRowInner({
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
debugId: `workspace:${workspace.workspaceKey}`,
|
||||
})
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -694,6 +668,15 @@ function WorkspaceRowInner({
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
@@ -731,6 +714,7 @@ function WorkspaceRowWithMenu({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -740,6 +724,7 @@ function WorkspaceRowWithMenu({
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
@@ -825,6 +810,11 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied('Path copied')
|
||||
}, [toast, workspace.workspaceId])
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name)
|
||||
toast.copied('Branch name copied')
|
||||
}, [toast, workspace.name])
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
@@ -841,6 +831,7 @@ function WorkspaceRowWithMenu({
|
||||
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
)
|
||||
@@ -1006,6 +997,7 @@ function WorkspaceRow({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -1015,6 +1007,7 @@ function WorkspaceRow({
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
@@ -1026,6 +1019,7 @@ function WorkspaceRow({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1093,6 +1087,7 @@ function ProjectBlock({
|
||||
selected={isSelected}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === 'git'}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
@@ -1131,6 +1126,13 @@ function ProjectBlock({
|
||||
[renderWorkspaceRow]
|
||||
)
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspaceEntry[]) => {
|
||||
onWorkspaceReorder(project.projectKey, workspaces)
|
||||
},
|
||||
[onWorkspaceReorder, project.projectKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{flattenedWorkspace ? (
|
||||
@@ -1176,9 +1178,9 @@ function ProjectBlock({
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
@@ -1193,9 +1195,11 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
isOpen = true,
|
||||
projects,
|
||||
serverId,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
onWorkspacePress,
|
||||
@@ -1205,16 +1209,9 @@ export function SidebarWorkspaceList({
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const pathname = usePathname()
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
const isTauri = getIsTauri()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||
(state) => state.setSidebarShortcutWorkspaceTargets
|
||||
)
|
||||
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
|
||||
(state) => state.setVisibleWorkspaceTargets
|
||||
)
|
||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
||||
@@ -1236,21 +1233,8 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||
const next = new Set<string>()
|
||||
for (const key of prev) {
|
||||
if (validProjectKeys.has(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [projects])
|
||||
|
||||
const projectIconRequests = useMemo(() => {
|
||||
if (!isOpen || !serverId) {
|
||||
if (!serverId) {
|
||||
return []
|
||||
}
|
||||
const unique = new Map<string, { serverId: string; cwd: string }>()
|
||||
@@ -1262,7 +1246,7 @@ export function SidebarWorkspaceList({
|
||||
unique.set(`${serverId}:${cwd}`, { serverId, cwd })
|
||||
}
|
||||
return Array.from(unique.values())
|
||||
}, [isOpen, projects, serverId])
|
||||
}, [projects, serverId])
|
||||
|
||||
const projectIconQueries = useQueries({
|
||||
queries: projectIconRequests.map((request) => ({
|
||||
@@ -1277,7 +1261,6 @@ export function SidebarWorkspaceList({
|
||||
},
|
||||
select: toProjectIconDataUri,
|
||||
enabled: Boolean(
|
||||
isOpen &&
|
||||
getHostRuntimeStore().getClient(request.serverId) &&
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
||||
request.cwd
|
||||
@@ -1316,44 +1299,6 @@ export function SidebarWorkspaceList({
|
||||
return byProject
|
||||
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
||||
|
||||
const shortcutModel = useMemo(
|
||||
() =>
|
||||
buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys,
|
||||
}),
|
||||
[collapsedProjectKeys, projects]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
|
||||
setVisibleWorkspaceTargets(shortcutModel.visibleTargets)
|
||||
}, [
|
||||
setSidebarShortcutWorkspaceTargets,
|
||||
setVisibleWorkspaceTargets,
|
||||
shortcutModel.shortcutTargets,
|
||||
shortcutModel.visibleTargets,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSidebarShortcutWorkspaceTargets([])
|
||||
setVisibleWorkspaceTargets([])
|
||||
}
|
||||
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets])
|
||||
|
||||
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectKey)) {
|
||||
next.delete(projectKey)
|
||||
} else {
|
||||
next.add(projectKey)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleProjectDragEnd = useCallback(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
@@ -1437,9 +1382,9 @@ export function SidebarWorkspaceList({
|
||||
serverId={serverId}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutModel.shortcutIndexByWorkspaceKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
||||
onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
@@ -1456,12 +1401,12 @@ export function SidebarWorkspaceList({
|
||||
handleCreateWorktree,
|
||||
handleWorkspaceReorder,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
projectIconByProjectKey,
|
||||
serverId,
|
||||
shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
toggleProjectCollapsed,
|
||||
isNative,
|
||||
]
|
||||
)
|
||||
@@ -1474,7 +1419,7 @@ export function SidebarWorkspaceList({
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={projects}
|
||||
keyExtractor={(project) => project.projectKey}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
scrollEnabled={false}
|
||||
@@ -1495,9 +1440,7 @@ export function SidebarWorkspaceList({
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollBeginDrag={() =>
|
||||
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||
}
|
||||
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
@@ -1507,9 +1450,7 @@ export function SidebarWorkspaceList({
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollBeginDrag={() =>
|
||||
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||
}
|
||||
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -24,7 +24,6 @@ const USER_SCROLL_DELTA_EPSILON = 1
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
|
||||
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||
#agent-chat-scroll-web-dom-scroll,
|
||||
#agent-chat-scroll-web-dom-virtualized {
|
||||
@@ -40,11 +39,8 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||
}
|
||||
`
|
||||
|
||||
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return
|
||||
}
|
||||
console.log('[WebStickyBottom]', event, details)
|
||||
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
|
||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||
}
|
||||
|
||||
function getDebugNow(): number | null {
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ComboboxProps {
|
||||
title?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
enableDismissOnClose?: boolean
|
||||
desktopPlacement?: 'top-start' | 'bottom-start'
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
@@ -224,6 +225,7 @@ export function Combobox({
|
||||
title = 'Select',
|
||||
open,
|
||||
onOpenChange,
|
||||
enableDismissOnClose,
|
||||
desktopPlacement = 'top-start',
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
@@ -235,6 +237,7 @@ export function Combobox({
|
||||
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null)
|
||||
const hasPresentedBottomSheetRef = useRef(false)
|
||||
const snapPoints = useMemo(() => ['60%', '90%'], [])
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||
null
|
||||
@@ -379,11 +382,20 @@ export function Combobox({
|
||||
useEffect(() => {
|
||||
if (!isMobile) return
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present()
|
||||
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
|
||||
bottomSheetRef.current?.snapToIndex(0)
|
||||
} else {
|
||||
hasPresentedBottomSheetRef.current = true
|
||||
bottomSheetRef.current?.present()
|
||||
}
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss()
|
||||
if (enableDismissOnClose === false) {
|
||||
bottomSheetRef.current?.close()
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss()
|
||||
}
|
||||
}
|
||||
}, [isOpen, isMobile])
|
||||
}, [enableDismissOnClose, isOpen, isMobile])
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
@@ -622,6 +634,7 @@ export function Combobox({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -182,6 +184,16 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as MutableRefObject<T>).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
@@ -231,6 +243,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile = false,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
@@ -239,6 +252,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile?: boolean;
|
||||
enabledOnWeb?: boolean;
|
||||
longPressDelayMs?: number;
|
||||
triggerRef?: Ref<View | null>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
@@ -268,10 +282,18 @@ export function ContextMenuTrigger({
|
||||
[ctx, disabled, shouldEnableOnThisPlatform]
|
||||
);
|
||||
|
||||
const handleRef = useCallback(
|
||||
(node: View | null) => {
|
||||
assignRef(ctx.triggerRef, node);
|
||||
assignRef(triggerRef, node);
|
||||
},
|
||||
[ctx.triggerRef, triggerRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
ref={handleRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
delayLongPress={longPressDelayMs}
|
||||
|
||||
@@ -170,10 +170,6 @@ export function useDropdownFloating({
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async () => {
|
||||
console.log("[useDropdownFloating] update called", {
|
||||
hasReferenceEl: !!referenceEl,
|
||||
hasFloatingEl: !!floatingElRef.current,
|
||||
});
|
||||
|
||||
if (!referenceEl || !floatingElRef.current) {
|
||||
return;
|
||||
@@ -185,12 +181,6 @@ export function useDropdownFloating({
|
||||
measureElement(floatingElRef.current),
|
||||
]);
|
||||
|
||||
console.log("[useDropdownFloating] measured", {
|
||||
fromRect,
|
||||
contentRect,
|
||||
displayArea,
|
||||
});
|
||||
|
||||
const result = computeGeometry({
|
||||
fromRect,
|
||||
contentSize: { width: contentRect.width, height: contentRect.height },
|
||||
@@ -201,8 +191,6 @@ export function useDropdownFloating({
|
||||
padding,
|
||||
});
|
||||
|
||||
console.log("[useDropdownFloating] geometry result", result);
|
||||
|
||||
setGeometry(result);
|
||||
} catch (e) {
|
||||
console.warn("[useDropdownFloating] measure failed:", e);
|
||||
@@ -210,7 +198,6 @@ export function useDropdownFloating({
|
||||
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
|
||||
|
||||
const floatingRef = useCallback((el: View | null) => {
|
||||
console.log("[useDropdownFloating] floatingRef called", { hasEl: !!el });
|
||||
floatingElRef.current = el;
|
||||
}, []);
|
||||
|
||||
@@ -218,17 +205,10 @@ export function useDropdownFloating({
|
||||
const [floatingReady, setFloatingReady] = useState(false);
|
||||
|
||||
const handleFloatingLayout = useCallback(() => {
|
||||
console.log("[useDropdownFloating] handleFloatingLayout called");
|
||||
setFloatingReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[useDropdownFloating] useEffect", {
|
||||
open,
|
||||
floatingReady,
|
||||
hasReferenceEl: !!referenceEl,
|
||||
hasFloatingEl: !!floatingElRef.current,
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
setGeometry(null);
|
||||
@@ -237,7 +217,6 @@ export function useDropdownFloating({
|
||||
}
|
||||
|
||||
if (floatingReady && referenceEl && floatingElRef.current) {
|
||||
console.log("[useDropdownFloating] calling update from useEffect");
|
||||
update();
|
||||
}
|
||||
}, [open, floatingReady, referenceEl, update]);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -53,6 +57,26 @@ function useTooltipContext(componentName: string): TooltipContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void
|
||||
): (event: E) => void {
|
||||
return (event: E) => {
|
||||
original?.(event);
|
||||
injected?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
@@ -224,8 +248,15 @@ export function TooltipTrigger({
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPress,
|
||||
asChild = false,
|
||||
triggerRefProp = "ref",
|
||||
...props
|
||||
}: PropsWithChildren<PressableProps>): ReactElement {
|
||||
}: PropsWithChildren<
|
||||
PressableProps & {
|
||||
asChild?: boolean;
|
||||
triggerRefProp?: string;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useTooltipContext("TooltipTrigger");
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -276,37 +307,86 @@ export function TooltipTrigger({
|
||||
[onHoverOut, close]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: any) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
},
|
||||
[clearOpenTimer, ctx, disabled, onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: any) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
},
|
||||
[close, onBlur]
|
||||
);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
},
|
||||
[close, onPress]
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
...props,
|
||||
disabled,
|
||||
onHoverIn: handleHoverIn,
|
||||
onHoverOut: handleHoverOut,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onPress: handlePress,
|
||||
...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null),
|
||||
};
|
||||
|
||||
if (asChild) {
|
||||
const child = Children.only(children);
|
||||
if (!isValidElement(child)) {
|
||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||
}
|
||||
|
||||
const childProps = child.props as Record<string, any>;
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
...triggerProps,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
|
||||
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
|
||||
onPress: composeEventHandlers(childProps.onPress, handlePress),
|
||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
|
||||
} as Record<string, any>;
|
||||
|
||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||
assignRef(existingRefProp, node);
|
||||
assignRef(ctx.triggerRef, node);
|
||||
};
|
||||
|
||||
return cloneElement(child, mergedProps);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
{...triggerProps}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onFocus={(e) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
}}
|
||||
onPress={(e) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
}}
|
||||
{...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null)}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
|
||||
@@ -132,8 +132,6 @@ function createDriverHarness(input?: {
|
||||
context.measurementState.offsetY = 720;
|
||||
});
|
||||
const modeChanges: BottomAnchorMode[] = [];
|
||||
const warnings: Array<{ agentId: string; reason: string }> = [];
|
||||
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
|
||||
const driver = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => context.agentId,
|
||||
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
||||
@@ -145,10 +143,6 @@ function createDriverHarness(input?: {
|
||||
onModeChange: (mode) => {
|
||||
modeChanges.push(mode);
|
||||
},
|
||||
log: (event, details) => {
|
||||
logs.push({ event, details });
|
||||
},
|
||||
warn: (details) => warnings.push(details),
|
||||
scheduleFrame: (params) => scheduler.schedule(params),
|
||||
cancelFrame: (handle) => scheduler.cancel(handle),
|
||||
});
|
||||
@@ -159,8 +153,6 @@ function createDriverHarness(input?: {
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
modeChanges,
|
||||
logs,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,7 +359,6 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.warnings).toEqual([]);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -110,8 +110,6 @@ type CreateBottomAnchorControllerDriverInput = {
|
||||
isNearBottom: () => boolean;
|
||||
scrollToBottom: (animated: boolean) => void;
|
||||
onModeChange: (mode: BottomAnchorMode) => void;
|
||||
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
|
||||
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
|
||||
scheduleFrame: (params: {
|
||||
kind: "attempt" | "verification";
|
||||
callback: () => void;
|
||||
@@ -123,17 +121,6 @@ type CreateBottomAnchorControllerDriverInput = {
|
||||
const MAX_VERIFICATION_RETRIES = 3;
|
||||
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
|
||||
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logBottomAnchorEvent(
|
||||
event: BottomAnchorEvent,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.debug("[BottomAnchor]", event, details);
|
||||
}
|
||||
|
||||
function scheduleAnimationFrameWithDelay(input: {
|
||||
callback: () => void;
|
||||
@@ -323,10 +310,6 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
blockedReason = nextBlockedReason;
|
||||
input.log(
|
||||
"blocked_reason_changed",
|
||||
getLogContext({ nextBlockedReason })
|
||||
);
|
||||
};
|
||||
|
||||
const setModeInternal = (nextMode: BottomAnchorMode) => {
|
||||
@@ -367,13 +350,6 @@ function createBottomAnchorControllerDriver(
|
||||
setBlockedReason(null);
|
||||
return;
|
||||
}
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: currentRequest.reason,
|
||||
cancelReason: reason,
|
||||
})
|
||||
);
|
||||
pendingRequest = null;
|
||||
cancelPendingAttempt();
|
||||
setBlockedReason(null);
|
||||
@@ -398,19 +374,6 @@ function createBottomAnchorControllerDriver(
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
}
|
||||
input.log(
|
||||
"verification_scheduled",
|
||||
getLogContext({
|
||||
retries: attemptContext.retries,
|
||||
startedContentHeight: attemptContext.startedContentHeight ?? null,
|
||||
startedOffsetY: attemptContext.startedOffsetY ?? null,
|
||||
startedViewportHeight: attemptContext.startedViewportHeight ?? null,
|
||||
scheduledMeasurementState:
|
||||
getDetailedMeasurementState(scheduledMeasurementState),
|
||||
verificationDelayFrames:
|
||||
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
|
||||
})
|
||||
);
|
||||
verificationHandle = input.scheduleFrame({
|
||||
kind: "verification",
|
||||
delayFrames:
|
||||
@@ -427,15 +390,6 @@ function createBottomAnchorControllerDriver(
|
||||
});
|
||||
|
||||
if (verificationBlockedReason) {
|
||||
input.log(
|
||||
"attempt_verified",
|
||||
getLogContext({
|
||||
verificationPhase: "blocked",
|
||||
verificationBlockedReason,
|
||||
retries: attemptContext.retries,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
pendingVerification = attemptContext;
|
||||
setBlockedReason(verificationBlockedReason);
|
||||
return;
|
||||
@@ -451,26 +405,6 @@ function createBottomAnchorControllerDriver(
|
||||
input.getTransportBehavior().verificationRetryMode,
|
||||
});
|
||||
|
||||
input.log(
|
||||
"attempt_verified",
|
||||
getLogContext({
|
||||
verifiedNearBottom,
|
||||
retries: attemptContext.retries,
|
||||
retryDisposition,
|
||||
contentHeightDeltaSinceAttempt:
|
||||
measurementState.contentHeight -
|
||||
(attemptContext.startedContentHeight ?? measurementState.contentHeight),
|
||||
offsetDeltaSinceAttempt:
|
||||
measurementState.offsetY -
|
||||
(attemptContext.startedOffsetY ?? measurementState.offsetY),
|
||||
viewportHeightDeltaSinceAttempt:
|
||||
measurementState.viewportHeight -
|
||||
(attemptContext.startedViewportHeight ??
|
||||
measurementState.viewportHeight),
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
|
||||
if (verifiedNearBottom) {
|
||||
if (
|
||||
isRequestAttempt &&
|
||||
@@ -494,7 +428,6 @@ function createBottomAnchorControllerDriver(
|
||||
pendingVerification = null;
|
||||
markStickyMeasurementVerified();
|
||||
if (isRequestAttempt) {
|
||||
input.log("request_fulfilled", getLogContext());
|
||||
pendingRequest = null;
|
||||
}
|
||||
setBlockedReason(null);
|
||||
@@ -520,21 +453,7 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
|
||||
input.log(
|
||||
"attempt_failed",
|
||||
getLogContext({
|
||||
retries: attemptContext.retries,
|
||||
retryDisposition,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
pendingVerification = null;
|
||||
if (isRequestAttempt && currentRequest) {
|
||||
input.warn({
|
||||
agentId: input.getAgentId(),
|
||||
reason: currentRequest.reason,
|
||||
});
|
||||
}
|
||||
setBlockedReason(
|
||||
isRequestAttempt ? "waiting_for_post_layout_verification" : null
|
||||
);
|
||||
@@ -552,14 +471,6 @@ function createBottomAnchorControllerDriver(
|
||||
startedViewportHeight: measurementState.viewportHeight,
|
||||
};
|
||||
pendingVerification = attemptContext;
|
||||
input.log(
|
||||
"attempt_started",
|
||||
getLogContext({
|
||||
animated,
|
||||
retries: attemptContext.retries,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
input.scrollToBottom(animated);
|
||||
scheduleVerification(attemptContext);
|
||||
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
|
||||
@@ -576,18 +487,6 @@ function createBottomAnchorControllerDriver(
|
||||
| "manual_reevaluate"
|
||||
| "retry_scroll"
|
||||
) => {
|
||||
input.log(
|
||||
"evaluate_called",
|
||||
getLogContext({
|
||||
evaluateReason: reason,
|
||||
animated,
|
||||
hasAttemptHandle: attemptHandle !== null,
|
||||
hasVerificationHandle: verificationHandle !== null,
|
||||
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
|
||||
pendingVerificationRetries: pendingVerification?.retries ?? null,
|
||||
measurementState: getDetailedMeasurementState(input.getMeasurementState()),
|
||||
})
|
||||
);
|
||||
if (attemptHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -610,17 +509,6 @@ function createBottomAnchorControllerDriver(
|
||||
!shouldAttemptForPendingRequest &&
|
||||
!shouldAttemptForStickyVerification
|
||||
) {
|
||||
input.log(
|
||||
"attempt_started",
|
||||
getLogContext({
|
||||
attemptPhase: "skipped",
|
||||
evaluateReason: reason,
|
||||
nextBlockedReason,
|
||||
shouldAttemptForPendingRequest,
|
||||
shouldAttemptForStickyVerification,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -630,17 +518,6 @@ function createBottomAnchorControllerDriver(
|
||||
};
|
||||
|
||||
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
|
||||
const existing = pendingRequest;
|
||||
if (existing) {
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: existing.reason,
|
||||
cancelReason: "replaced_by_new_request",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
cancelPendingAttempt();
|
||||
const nextRequest: BottomAnchorRequest = {
|
||||
id: requestSequence + 1,
|
||||
@@ -659,10 +536,6 @@ function createBottomAnchorControllerDriver(
|
||||
? "sticky-bottom"
|
||||
: __private__.deriveModeForLocalRequest({ reason: request.reason })
|
||||
);
|
||||
input.log(
|
||||
"request_created",
|
||||
getLogContext({ requestReason: request.reason })
|
||||
);
|
||||
evaluate(request.reason === "jump-to-bottom", "request_created");
|
||||
};
|
||||
|
||||
@@ -707,7 +580,6 @@ function createBottomAnchorControllerDriver(
|
||||
}
|
||||
cancelPendingRequest("user_scrolled_away");
|
||||
setModeInternal("detached");
|
||||
input.log("detached_by_user", getLogContext());
|
||||
},
|
||||
handleViewportMetricsChange(params) {
|
||||
if (
|
||||
@@ -905,10 +777,6 @@ export function useBottomAnchorController(input: {
|
||||
isNearBottom: () => isNearBottomRef.current(),
|
||||
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
|
||||
onModeChange: (nextMode) => setMode(nextMode),
|
||||
log: (event, details) => logBottomAnchorEvent(event, details),
|
||||
warn: (details) => {
|
||||
console.warn("[BottomAnchor] request could not be fulfilled", details);
|
||||
},
|
||||
scheduleFrame: ({ callback, delayFrames }) =>
|
||||
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
|
||||
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
|
||||
|
||||
@@ -2,21 +2,18 @@ import { ActivityIndicator, Alert, Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { VolumeMeter } from "@/components/volume-meter";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
|
||||
export function VoiceCompactIndicator() {
|
||||
const { theme } = useUnistyles();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
isVoiceMode,
|
||||
isVoiceSwitching,
|
||||
volume,
|
||||
isMuted,
|
||||
isDetecting,
|
||||
isSpeaking,
|
||||
toggleMute,
|
||||
stopVoice,
|
||||
} = useVoice();
|
||||
|
||||
if (!isVoiceMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,19 @@ import { View, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MicOff, Square } from "lucide-react-native";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
export function VoicePanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
volume,
|
||||
isMuted,
|
||||
isDetecting,
|
||||
isSpeaking,
|
||||
stopVoice,
|
||||
toggleMute,
|
||||
activeServerId,
|
||||
} = useVoice();
|
||||
|
||||
const hostLabel = activeServerId
|
||||
? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null
|
||||
: null;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { View } from "react-native";
|
||||
import ReanimatedAnimated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -41,9 +40,7 @@ export function VolumeMeter({
|
||||
orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
|
||||
|
||||
// Create shared values for 3 dots unconditionally
|
||||
const line1Height = useSharedValue(MIN_HEIGHT);
|
||||
const line2Height = useSharedValue(MIN_HEIGHT);
|
||||
const line3Height = useSharedValue(MIN_HEIGHT);
|
||||
const animatedVolume = useSharedValue(0);
|
||||
const line1Pulse = useSharedValue(1);
|
||||
const line2Pulse = useSharedValue(1);
|
||||
const line3Pulse = useSharedValue(1);
|
||||
@@ -90,54 +87,19 @@ export function VolumeMeter({
|
||||
);
|
||||
}, [isMuted]);
|
||||
|
||||
// Update heights based on volume with different responsiveness for all dots
|
||||
// Drive a single animated volume value and derive the individual bar heights
|
||||
// on the UI thread instead of scheduling three independent springs per sample.
|
||||
useEffect(() => {
|
||||
if (isMuted) {
|
||||
// When muted, keep all lines at minimum height without animation
|
||||
line1Height.value = MIN_HEIGHT;
|
||||
line2Height.value = MIN_HEIGHT;
|
||||
line3Height.value = MIN_HEIGHT;
|
||||
animatedVolume.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (volume > 0.001) {
|
||||
// Active volume - animate heights based on volume
|
||||
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
|
||||
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
|
||||
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
|
||||
|
||||
line1Height.value = withSpring(target1, {
|
||||
damping: 10,
|
||||
stiffness: 200,
|
||||
});
|
||||
|
||||
line2Height.value = withSpring(target2, {
|
||||
damping: 12.5,
|
||||
stiffness: 175,
|
||||
});
|
||||
|
||||
line3Height.value = withSpring(target3, {
|
||||
damping: 15,
|
||||
stiffness: 150,
|
||||
});
|
||||
} else {
|
||||
// No volume - return to minimum
|
||||
line1Height.value = withSpring(MIN_HEIGHT, {
|
||||
damping: 20,
|
||||
stiffness: 150,
|
||||
});
|
||||
|
||||
line2Height.value = withSpring(MIN_HEIGHT, {
|
||||
damping: 20,
|
||||
stiffness: 150,
|
||||
});
|
||||
|
||||
line3Height.value = withSpring(MIN_HEIGHT, {
|
||||
damping: 20,
|
||||
stiffness: 150,
|
||||
});
|
||||
}
|
||||
}, [volume, isMuted]);
|
||||
animatedVolume.value = withTiming(volume, {
|
||||
duration: volume > animatedVolume.value ? 70 : 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}, [animatedVolume, isMuted, volume]);
|
||||
|
||||
const lineColor = color ?? theme.colors.foreground;
|
||||
const containerHeight =
|
||||
@@ -147,9 +109,12 @@ export function VolumeMeter({
|
||||
const line1Style = useAnimatedStyle(() => {
|
||||
const isActive = isSpeaking;
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
|
||||
const currentVolume = isMuted ? 0 : animatedVolume.value;
|
||||
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.2);
|
||||
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
|
||||
return {
|
||||
height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value),
|
||||
height:
|
||||
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line1Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
@@ -157,9 +122,12 @@ export function VolumeMeter({
|
||||
const line2Style = useAnimatedStyle(() => {
|
||||
const isActive = isSpeaking;
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
|
||||
const currentVolume = isMuted ? 0 : animatedVolume.value;
|
||||
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.05);
|
||||
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
|
||||
return {
|
||||
height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value),
|
||||
height:
|
||||
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
@@ -167,9 +135,12 @@ export function VolumeMeter({
|
||||
const line3Style = useAnimatedStyle(() => {
|
||||
const isActive = isSpeaking;
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
|
||||
const currentVolume = isMuted ? 0 : animatedVolume.value;
|
||||
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 0.9);
|
||||
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
|
||||
return {
|
||||
height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value),
|
||||
height:
|
||||
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
@@ -87,7 +87,7 @@ export interface WelcomeScreenProps {
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const { updateHost } = useDaemonRegistry();
|
||||
const { renameHost } = useHostMutations();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
@@ -201,7 +201,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
||||
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hostHasDirectEndpoint,
|
||||
registryHasDirectEndpoint,
|
||||
reconcileDesktopStartupRegistry,
|
||||
resolveManagedDesktopStartupStatus,
|
||||
type HostProfile,
|
||||
} from './daemon-registry-context'
|
||||
|
||||
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): HostProfile {
|
||||
const now = '2026-01-01T00:00:00.000Z'
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? input.serverId,
|
||||
lifecycle: input.lifecycle ?? {
|
||||
managed: false,
|
||||
managedRuntimeId: null,
|
||||
managedRuntimeVersion: null,
|
||||
associatedServerId: null,
|
||||
},
|
||||
connections: input.connections ?? [],
|
||||
preferredConnectionId: input.preferredConnectionId ?? null,
|
||||
createdAt: input.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? now,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hostHasDirectEndpoint', () => {
|
||||
it('returns true when host has matching direct endpoint', () => {
|
||||
const host = makeHost({
|
||||
serverId: 'srv_local',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
})
|
||||
|
||||
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when only relay connections exist', () => {
|
||||
const host = makeHost({
|
||||
serverId: 'srv_relay',
|
||||
connections: [
|
||||
{
|
||||
id: 'relay:relay.example:443',
|
||||
type: 'relay',
|
||||
relayEndpoint: 'relay.example:443',
|
||||
daemonPublicKeyB64: 'abcd',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'relay:relay.example:443',
|
||||
})
|
||||
|
||||
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registryHasDirectEndpoint', () => {
|
||||
it('returns true when any host contains the direct endpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
makeHost({
|
||||
serverId: 'srv_two',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no host has the endpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileDesktopStartupRegistry', () => {
|
||||
it('seeds managed and localhost connections as normal host entries', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const result = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_managed',
|
||||
hostname: 'managed-host',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_managed',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_localhost',
|
||||
hostname: 'local-dev',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
makeHost({
|
||||
serverId: 'srv_managed',
|
||||
label: 'managed-host',
|
||||
lifecycle: {
|
||||
managed: true,
|
||||
managedRuntimeId: 'runtime_1',
|
||||
managedRuntimeVersion: '1.2.3',
|
||||
associatedServerId: 'srv_managed',
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
type: 'directSocket',
|
||||
path: '/Users/test/.paseo-test/paseo.sock',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
makeHost({
|
||||
serverId: 'srv_localhost',
|
||||
label: 'local-dev',
|
||||
connections: [
|
||||
{
|
||||
id: 'direct:localhost:6767',
|
||||
type: 'directTcp',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps managed and localhost connections together when they resolve to the same server', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const result = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
makeHost({
|
||||
serverId: 'srv_shared',
|
||||
label: 'devbox',
|
||||
lifecycle: {
|
||||
managed: true,
|
||||
managedRuntimeId: 'runtime_1',
|
||||
managedRuntimeVersion: '1.2.3',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
type: 'directSocket',
|
||||
path: '/Users/test/.paseo-test/paseo.sock',
|
||||
},
|
||||
{
|
||||
id: 'direct:localhost:6767',
|
||||
type: 'directTcp',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('is idempotent for repeated desktop startup reconciliation', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const first = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
const second = reconcileDesktopStartupRegistry({
|
||||
existing: first,
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now: '2026-03-09T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveManagedDesktopStartupStatus', () => {
|
||||
it('starts the managed daemon when management is enabled', async () => {
|
||||
const managedStatus = {
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
runtimeRoot: '/runtime',
|
||||
managedHome: '/home',
|
||||
transportType: 'socket',
|
||||
transportPath: '/tmp/paseo.sock',
|
||||
daemonPid: 123,
|
||||
daemonRunning: true,
|
||||
daemonStatus: 'running',
|
||||
logPath: '/tmp/daemon.log',
|
||||
serverId: 'srv_managed',
|
||||
hostname: 'managed-host',
|
||||
relayEnabled: true,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
}
|
||||
let startCalls = 0
|
||||
let statusCalls = 0
|
||||
|
||||
const result = await resolveManagedDesktopStartupStatus({
|
||||
settings: { manageBuiltInDaemon: true },
|
||||
startManagedDaemonFn: async () => {
|
||||
startCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
getManagedDaemonStatusFn: async () => {
|
||||
statusCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(managedStatus)
|
||||
expect(startCalls).toBe(1)
|
||||
expect(statusCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('only reads managed daemon status when management is paused', async () => {
|
||||
const managedStatus = {
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
runtimeRoot: '/runtime',
|
||||
managedHome: '/home',
|
||||
transportType: 'socket',
|
||||
transportPath: '/tmp/paseo.sock',
|
||||
daemonPid: null,
|
||||
daemonRunning: false,
|
||||
daemonStatus: 'stopped',
|
||||
logPath: '/tmp/daemon.log',
|
||||
serverId: null,
|
||||
hostname: null,
|
||||
relayEnabled: true,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
}
|
||||
let startCalls = 0
|
||||
let statusCalls = 0
|
||||
|
||||
const result = await resolveManagedDesktopStartupStatus({
|
||||
settings: { manageBuiltInDaemon: false },
|
||||
startManagedDaemonFn: async () => {
|
||||
startCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
getManagedDaemonStatusFn: async () => {
|
||||
statusCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(managedStatus)
|
||||
expect(startCalls).toBe(0)
|
||||
expect(statusCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,941 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
||||
import { probeConnection } from '@/utils/test-daemon-connection'
|
||||
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
|
||||
import {
|
||||
getManagedDaemonStatus,
|
||||
type ManagedDaemonStatus,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
} from '@/desktop/managed-runtime/managed-runtime'
|
||||
|
||||
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
|
||||
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
|
||||
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
|
||||
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
|
||||
const E2E_STORAGE_KEY = '@paseo:e2e'
|
||||
|
||||
export type DirectTcpHostConnection = {
|
||||
id: string
|
||||
type: 'directTcp'
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type DirectSocketHostConnection = {
|
||||
id: string
|
||||
type: 'directSocket'
|
||||
path: string
|
||||
}
|
||||
|
||||
export type DirectPipeHostConnection = {
|
||||
id: string
|
||||
type: 'directPipe'
|
||||
path: string
|
||||
}
|
||||
|
||||
export type RelayHostConnection = {
|
||||
id: string
|
||||
type: 'relay'
|
||||
relayEndpoint: string
|
||||
daemonPublicKeyB64: string
|
||||
}
|
||||
|
||||
export type HostConnection =
|
||||
| DirectTcpHostConnection
|
||||
| DirectSocketHostConnection
|
||||
| DirectPipeHostConnection
|
||||
| RelayHostConnection
|
||||
|
||||
export type HostLifecycle = {
|
||||
managed: boolean
|
||||
managedRuntimeId: string | null
|
||||
managedRuntimeVersion: string | null
|
||||
associatedServerId: string | null
|
||||
}
|
||||
|
||||
export type HostProfile = {
|
||||
serverId: string
|
||||
label: string
|
||||
lifecycle: HostLifecycle
|
||||
connections: HostConnection[]
|
||||
preferredConnectionId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
|
||||
|
||||
export type ManagedHostReconciliationInput = {
|
||||
serverId: string
|
||||
hostname?: string | null
|
||||
runtimeId: string
|
||||
runtimeVersion: string
|
||||
transportType: string
|
||||
transportPath: string
|
||||
associatedServerId?: string | null
|
||||
}
|
||||
|
||||
export type LocalhostHostReconciliationInput = {
|
||||
serverId: string
|
||||
hostname: string | null
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type DesktopStartupReconciliationInput = {
|
||||
existing: HostProfile[]
|
||||
managed: ManagedHostReconciliationInput | null
|
||||
localhost: LocalhostHostReconciliationInput | null
|
||||
now?: string
|
||||
}
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[]
|
||||
isLoading: boolean
|
||||
isReconciling: boolean
|
||||
error: unknown | null
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string
|
||||
endpoint: string
|
||||
label?: string
|
||||
}) => Promise<HostProfile>
|
||||
upsertRelayConnection: (input: {
|
||||
serverId: string
|
||||
relayEndpoint: string
|
||||
daemonPublicKeyB64: string
|
||||
label?: string
|
||||
}) => Promise<HostProfile>
|
||||
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
|
||||
removeHost: (serverId: string) => Promise<void>
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>
|
||||
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
|
||||
|
||||
function defaultLifecycle(): HostLifecycle {
|
||||
return {
|
||||
managed: false,
|
||||
managedRuntimeId: null,
|
||||
managedRuntimeVersion: null,
|
||||
associatedServerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
|
||||
const trimmed = value?.trim() ?? ''
|
||||
return trimmed.length > 0 ? trimmed : serverId
|
||||
}
|
||||
|
||||
function normalizeEndpointOrNull(endpoint: string): string | null {
|
||||
try {
|
||||
return normalizeHostPort(endpoint)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeManagedTransportConnection(input: {
|
||||
transportType: string
|
||||
transportPath: string
|
||||
}): HostConnection | null {
|
||||
const transportPath = input.transportPath.trim()
|
||||
if (!transportPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (input.transportType === 'tcp') {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(transportPath)
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: 'directTcp',
|
||||
endpoint,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (input.transportType === 'pipe') {
|
||||
return {
|
||||
id: `pipe:${transportPath}`,
|
||||
type: 'directPipe',
|
||||
path: transportPath,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.transportType === 'socket') {
|
||||
return {
|
||||
id: `socket:${transportPath}`,
|
||||
type: 'directSocket',
|
||||
path: transportPath,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
if (!connection || typeof connection !== 'object') {
|
||||
return null
|
||||
}
|
||||
const record = connection as Record<string, unknown>
|
||||
const type = typeof record.type === 'string' ? record.type : null
|
||||
if (type === 'directTcp') {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
|
||||
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (type === 'directSocket') {
|
||||
const path = String(record.path ?? '').trim()
|
||||
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
|
||||
}
|
||||
if (type === 'directPipe') {
|
||||
const path = String(record.path ?? '').trim()
|
||||
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
|
||||
}
|
||||
if (type === 'relay') {
|
||||
try {
|
||||
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
|
||||
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
|
||||
if (!daemonPublicKeyB64) return null
|
||||
return {
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: 'relay',
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle {
|
||||
const record =
|
||||
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
|
||||
|
||||
return {
|
||||
managed: record?.managed === true,
|
||||
managedRuntimeId:
|
||||
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
|
||||
managedRuntimeVersion:
|
||||
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
|
||||
associatedServerId:
|
||||
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null
|
||||
}
|
||||
const record = entry as Record<string, unknown>
|
||||
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
|
||||
if (!serverId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawConnections = Array.isArray(record.connections) ? record.connections : []
|
||||
const connections = rawConnections
|
||||
.map((connection) => normalizeStoredConnection(connection))
|
||||
.filter((connection): connection is HostConnection => connection !== null)
|
||||
if (connections.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lifecycle = normalizeStoredLifecycle(record.lifecycle)
|
||||
const now = new Date().toISOString()
|
||||
const label = normalizeHostLabel(
|
||||
typeof record.label === 'string' ? record.label : null,
|
||||
serverId
|
||||
)
|
||||
const preferredConnectionId =
|
||||
typeof record.preferredConnectionId === 'string' &&
|
||||
connections.some((connection) => connection.id === record.preferredConnectionId)
|
||||
? record.preferredConnectionId
|
||||
: connections[0]?.id ?? null
|
||||
|
||||
return {
|
||||
serverId,
|
||||
label,
|
||||
lifecycle,
|
||||
connections,
|
||||
preferredConnectionId,
|
||||
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
|
||||
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
|
||||
}
|
||||
}
|
||||
|
||||
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
|
||||
if (left.type !== right.type || left.id !== right.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (left.type === 'directTcp' && right.type === 'directTcp') {
|
||||
return left.endpoint === right.endpoint
|
||||
}
|
||||
if (left.type === 'directSocket' && right.type === 'directSocket') {
|
||||
return left.path === right.path
|
||||
}
|
||||
if (left.type === 'directPipe' && right.type === 'directPipe') {
|
||||
return left.path === right.path
|
||||
}
|
||||
if (left.type === 'relay' && right.type === 'relay') {
|
||||
return (
|
||||
left.relayEndpoint === right.relayEndpoint &&
|
||||
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
|
||||
return (
|
||||
left.managed === right.managed &&
|
||||
left.managedRuntimeId === right.managedRuntimeId &&
|
||||
left.managedRuntimeVersion === right.managedRuntimeVersion &&
|
||||
left.associatedServerId === right.associatedServerId
|
||||
)
|
||||
}
|
||||
|
||||
function upsertHostConnectionInProfiles(input: {
|
||||
profiles: HostProfile[]
|
||||
serverId: string
|
||||
label?: string
|
||||
lifecycle?: Partial<HostLifecycle>
|
||||
connection: HostConnection
|
||||
now?: string
|
||||
}): HostProfile[] {
|
||||
const serverId = input.serverId.trim()
|
||||
if (!serverId) {
|
||||
throw new Error('serverId is required')
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date().toISOString()
|
||||
const labelTrimmed = input.label?.trim() ?? ''
|
||||
const derivedLabel = labelTrimmed || serverId
|
||||
const existing = input.profiles
|
||||
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
|
||||
|
||||
if (idx === -1) {
|
||||
const profile: HostProfile = {
|
||||
serverId,
|
||||
label: derivedLabel,
|
||||
lifecycle: {
|
||||
...defaultLifecycle(),
|
||||
...(input.lifecycle ?? {}),
|
||||
},
|
||||
connections: [input.connection],
|
||||
preferredConnectionId: input.connection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
return [...existing, profile]
|
||||
}
|
||||
|
||||
const prev = existing[idx]!
|
||||
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
|
||||
const hadConnection = connectionIdx !== -1
|
||||
const connectionChanged =
|
||||
connectionIdx === -1
|
||||
? true
|
||||
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
|
||||
const nextConnections =
|
||||
connectionIdx === -1
|
||||
? [...prev.connections, input.connection]
|
||||
: connectionChanged
|
||||
? prev.connections.map((connection, index) =>
|
||||
index === connectionIdx ? input.connection : connection
|
||||
)
|
||||
: prev.connections
|
||||
|
||||
const nextLifecycle = {
|
||||
...prev.lifecycle,
|
||||
...(input.lifecycle ?? {}),
|
||||
}
|
||||
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
|
||||
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
|
||||
const changed =
|
||||
nextLabel !== prev.label ||
|
||||
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
||||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
||||
!hadConnection ||
|
||||
connectionChanged
|
||||
|
||||
if (!changed) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const nextProfile: HostProfile = {
|
||||
...prev,
|
||||
label: nextLabel,
|
||||
lifecycle: nextLifecycle,
|
||||
connections: nextConnections,
|
||||
preferredConnectionId: nextPreferredConnectionId,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const next = [...existing]
|
||||
next[idx] = nextProfile
|
||||
return next
|
||||
}
|
||||
|
||||
function reconcileManagedHostInProfiles(input: {
|
||||
profiles: HostProfile[]
|
||||
managed: ManagedHostReconciliationInput
|
||||
now?: string
|
||||
}): HostProfile[] {
|
||||
const connection = normalizeManagedTransportConnection(input.managed)
|
||||
if (!connection) {
|
||||
throw new Error(`Unsupported managed daemon transport: ${input.managed.transportType}`)
|
||||
}
|
||||
|
||||
const nextBase = input.profiles.filter((daemon) => {
|
||||
return !daemon.lifecycle.managed || daemon.serverId === input.managed.serverId
|
||||
})
|
||||
const profiles = nextBase.length === input.profiles.length ? input.profiles : nextBase
|
||||
|
||||
return upsertHostConnectionInProfiles({
|
||||
profiles,
|
||||
serverId: input.managed.serverId,
|
||||
label: input.managed.hostname ?? undefined,
|
||||
lifecycle: {
|
||||
managed: true,
|
||||
managedRuntimeId: input.managed.runtimeId,
|
||||
managedRuntimeVersion: input.managed.runtimeVersion,
|
||||
associatedServerId:
|
||||
input.managed.associatedServerId?.trim() || input.managed.serverId,
|
||||
},
|
||||
connection,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
export function reconcileDesktopStartupRegistry(
|
||||
input: DesktopStartupReconciliationInput
|
||||
): HostProfile[] {
|
||||
let next = input.existing
|
||||
|
||||
if (input.managed) {
|
||||
next = reconcileManagedHostInProfiles({
|
||||
profiles: next,
|
||||
managed: input.managed,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.localhost) {
|
||||
next = upsertHostConnectionInProfiles({
|
||||
profiles: next,
|
||||
serverId: input.localhost.serverId,
|
||||
label: input.localhost.hostname ?? undefined,
|
||||
connection: {
|
||||
id: `direct:${input.localhost.endpoint}`,
|
||||
type: 'directTcp',
|
||||
endpoint: input.localhost.endpoint,
|
||||
},
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveManagedDesktopStartupStatus(input: {
|
||||
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
|
||||
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
|
||||
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
|
||||
}): Promise<ManagedDaemonStatus> {
|
||||
if (input.settings.manageBuiltInDaemon) {
|
||||
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
|
||||
}
|
||||
|
||||
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
|
||||
}
|
||||
|
||||
async function probeManagedStartupTarget(input: {
|
||||
managedDaemon: ManagedDaemonStatus
|
||||
cancelled?: () => boolean
|
||||
}): Promise<ManagedHostReconciliationInput | null> {
|
||||
const connection = normalizeManagedTransportConnection({
|
||||
transportType: input.managedDaemon.transportType,
|
||||
transportPath: input.managedDaemon.transportPath,
|
||||
})
|
||||
if (!connection) {
|
||||
return null
|
||||
}
|
||||
|
||||
let serverId = input.managedDaemon.serverId
|
||||
let hostname = input.managedDaemon.hostname
|
||||
|
||||
if (!serverId) {
|
||||
const probed = await probeConnection(connection, {
|
||||
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
|
||||
})
|
||||
if (input.cancelled?.()) {
|
||||
throw new Error('Managed daemon bootstrap cancelled')
|
||||
}
|
||||
serverId = probed.serverId
|
||||
hostname = hostname ?? probed.hostname
|
||||
}
|
||||
|
||||
return {
|
||||
serverId,
|
||||
hostname,
|
||||
runtimeId: input.managedDaemon.runtimeId,
|
||||
runtimeVersion: input.managedDaemon.runtimeVersion,
|
||||
transportType: input.managedDaemon.transportType,
|
||||
transportPath: input.managedDaemon.transportPath,
|
||||
associatedServerId: input.managedDaemon.serverId,
|
||||
}
|
||||
}
|
||||
|
||||
async function probeManagedConnectionUntilReady(
|
||||
input: {
|
||||
managedDaemon: ManagedDaemonStatus
|
||||
cancelled?: () => boolean
|
||||
}
|
||||
): Promise<ManagedHostReconciliationInput | null> {
|
||||
const startedAt = Date.now()
|
||||
let lastError: unknown = null
|
||||
|
||||
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
|
||||
if (input.cancelled?.()) {
|
||||
throw new Error('Managed daemon bootstrap cancelled')
|
||||
}
|
||||
|
||||
try {
|
||||
return await probeManagedStartupTarget(input)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (input.cancelled?.()) {
|
||||
throw error
|
||||
}
|
||||
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Managed daemon bootstrap timed out')
|
||||
}
|
||||
|
||||
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
|
||||
const normalized = normalizeEndpointOrNull(endpoint)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
return host.connections.some(
|
||||
(connection) => connection.type === 'directTcp' && connection.endpoint === normalized
|
||||
)
|
||||
}
|
||||
|
||||
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
|
||||
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
|
||||
}
|
||||
|
||||
export function useDaemonRegistry(): DaemonRegistryContextValue {
|
||||
const ctx = useContext(DaemonRegistryContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const desktopStartupReconciledRef = useRef(false)
|
||||
const localhostBootstrapAttemptedRef = useRef(false)
|
||||
const [isReconciling, setIsReconciling] = useState(true)
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings()
|
||||
const {
|
||||
data: daemons = [],
|
||||
isPending,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: DAEMON_REGISTRY_QUERY_KEY,
|
||||
queryFn: loadDaemonRegistryFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
})
|
||||
|
||||
const persist = useCallback(
|
||||
async (profiles: HostProfile[]) => {
|
||||
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
|
||||
},
|
||||
[queryClient]
|
||||
)
|
||||
|
||||
const readDaemons = useCallback(() => {
|
||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
|
||||
}, [queryClient, daemons])
|
||||
|
||||
const updateHost = useCallback(
|
||||
async (serverId: string, updates: UpdateHostInput) => {
|
||||
const next = readDaemons().map((daemon) =>
|
||||
daemon.serverId === serverId
|
||||
? {
|
||||
...daemon,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: daemon
|
||||
)
|
||||
await persist(next)
|
||||
},
|
||||
[persist, readDaemons]
|
||||
)
|
||||
|
||||
const removeHost = useCallback(
|
||||
async (serverId: string) => {
|
||||
const existing = readDaemons()
|
||||
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
|
||||
await persist(remaining)
|
||||
},
|
||||
[persist, readDaemons]
|
||||
)
|
||||
|
||||
const removeConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
const existing = readDaemons()
|
||||
const now = new Date().toISOString()
|
||||
const next = existing
|
||||
.map((daemon) => {
|
||||
if (daemon.serverId !== serverId) return daemon
|
||||
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
|
||||
if (remaining.length === 0) {
|
||||
return null
|
||||
}
|
||||
const preferred =
|
||||
daemon.preferredConnectionId === connectionId
|
||||
? (remaining[0]?.id ?? null)
|
||||
: daemon.preferredConnectionId
|
||||
return {
|
||||
...daemon,
|
||||
connections: remaining,
|
||||
preferredConnectionId: preferred,
|
||||
updatedAt: now,
|
||||
} satisfies HostProfile
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null)
|
||||
await persist(next)
|
||||
},
|
||||
[persist, readDaemons]
|
||||
)
|
||||
|
||||
const upsertHostConnection = useCallback(
|
||||
async (input: {
|
||||
serverId: string
|
||||
label?: string
|
||||
lifecycle?: Partial<HostLifecycle>
|
||||
connection: HostConnection
|
||||
}) => {
|
||||
const now = new Date().toISOString()
|
||||
const next = upsertHostConnectionInProfiles({
|
||||
profiles: readDaemons(),
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
lifecycle: input.lifecycle,
|
||||
connection: input.connection,
|
||||
now,
|
||||
})
|
||||
await persist(next)
|
||||
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
|
||||
},
|
||||
[persist, readDaemons]
|
||||
)
|
||||
|
||||
const upsertDirectConnection = useCallback(
|
||||
async (input: { serverId: string; endpoint: string; label?: string }) => {
|
||||
const endpoint = normalizeHostPort(input.endpoint)
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: {
|
||||
id: `direct:${endpoint}`,
|
||||
type: 'directTcp',
|
||||
endpoint,
|
||||
},
|
||||
})
|
||||
},
|
||||
[upsertHostConnection]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (settingsLoading) return
|
||||
if (!shouldUseManagedDesktopDaemon()) return
|
||||
if (desktopStartupReconciledRef.current) return
|
||||
desktopStartupReconciledRef.current = true
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const reconcileDesktopStartup = async () => {
|
||||
try {
|
||||
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
|
||||
if (cancelled || isE2E) {
|
||||
return
|
||||
}
|
||||
|
||||
let managed: ManagedHostReconciliationInput | null = null
|
||||
try {
|
||||
const managedDaemon = await resolveManagedDesktopStartupStatus({
|
||||
settings,
|
||||
})
|
||||
if (managedDaemon.daemonRunning) {
|
||||
managed = await probeManagedConnectionUntilReady({
|
||||
managedDaemon,
|
||||
cancelled: () => cancelled,
|
||||
})
|
||||
}
|
||||
} catch (managedBootstrapError) {
|
||||
if (!cancelled) {
|
||||
console.warn(
|
||||
'[DaemonRegistry] Failed to reconcile managed daemon transport',
|
||||
managedBootstrapError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let localhost: LocalhostHostReconciliationInput | null = null
|
||||
|
||||
try {
|
||||
const { serverId, hostname } = await probeConnection(
|
||||
{
|
||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||
type: 'directTcp',
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
},
|
||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||
)
|
||||
if (!cancelled) {
|
||||
localhost = {
|
||||
serverId,
|
||||
hostname,
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = readDaemons()
|
||||
const next = reconcileDesktopStartupRegistry({
|
||||
existing,
|
||||
managed,
|
||||
localhost,
|
||||
})
|
||||
|
||||
if (next !== existing) {
|
||||
await persist(next)
|
||||
}
|
||||
} catch (reconciliationError) {
|
||||
if (cancelled) return
|
||||
console.warn(
|
||||
'[DaemonRegistry] Failed to reconcile desktop startup host connections',
|
||||
reconciliationError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void reconcileDesktopStartup().finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsReconciling(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
isPending,
|
||||
persist,
|
||||
readDaemons,
|
||||
settings.manageBuiltInDaemon,
|
||||
settingsLoading,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (shouldUseManagedDesktopDaemon()) return
|
||||
if (localhostBootstrapAttemptedRef.current) return
|
||||
localhostBootstrapAttemptedRef.current = true
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const bootstrapLocalhost = async () => {
|
||||
try {
|
||||
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
|
||||
if (cancelled || isE2E) {
|
||||
return
|
||||
}
|
||||
|
||||
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
|
||||
if (cancelled || alreadyHandled) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = readDaemons()
|
||||
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
|
||||
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { serverId, hostname } = await probeConnection(
|
||||
{
|
||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||
type: 'directTcp',
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
},
|
||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||
)
|
||||
if (cancelled) return
|
||||
|
||||
await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
label: hostname ?? undefined,
|
||||
})
|
||||
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
||||
} catch {
|
||||
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
|
||||
}
|
||||
} catch (bootstrapError) {
|
||||
if (cancelled) return
|
||||
console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrapLocalhost().finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsReconciling(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
isPending,
|
||||
readDaemons,
|
||||
upsertDirectConnection,
|
||||
])
|
||||
|
||||
const upsertRelayConnection = useCallback(
|
||||
async (input: {
|
||||
serverId: string
|
||||
relayEndpoint: string
|
||||
daemonPublicKeyB64: string
|
||||
label?: string
|
||||
}) => {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint)
|
||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
|
||||
if (!daemonPublicKeyB64) {
|
||||
throw new Error('daemonPublicKeyB64 is required')
|
||||
}
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: {
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: 'relay',
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
},
|
||||
})
|
||||
},
|
||||
[upsertHostConnection]
|
||||
)
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOffer) => {
|
||||
return upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
})
|
||||
},
|
||||
[upsertRelayConnection]
|
||||
)
|
||||
|
||||
const upsertDaemonFromOfferUrl = useCallback(
|
||||
async (offerUrlOrFragment: string) => {
|
||||
const marker = '#offer='
|
||||
const idx = offerUrlOrFragment.indexOf(marker)
|
||||
if (idx === -1) {
|
||||
throw new Error('Missing #offer= fragment')
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
|
||||
if (!encoded) {
|
||||
throw new Error('Offer payload is empty')
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded)
|
||||
const offer = ConnectionOfferSchema.parse(payload)
|
||||
return upsertDaemonFromOffer(offer)
|
||||
},
|
||||
[upsertDaemonFromOffer]
|
||||
)
|
||||
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
isReconciling,
|
||||
error: error ?? null,
|
||||
upsertDirectConnection,
|
||||
upsertRelayConnection,
|
||||
updateHost,
|
||||
removeHost,
|
||||
removeConnection,
|
||||
upsertDaemonFromOffer,
|
||||
upsertDaemonFromOfferUrl,
|
||||
}
|
||||
|
||||
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||
if (!stored) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((entry) => normalizeStoredHostProfile(entry))
|
||||
.filter((entry): entry is HostProfile => entry !== null)
|
||||
} catch (error) {
|
||||
console.error('[DaemonRegistry] Failed to load daemon registry', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,6 @@ 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>;
|
||||
backdropOpacity: SharedValue<number>;
|
||||
@@ -66,23 +54,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
@@ -29,8 +29,13 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { File } from "expo-file-system";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeSession,
|
||||
useHostRuntimeIsConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
useVoiceAudioEngineOptional,
|
||||
useVoiceRuntimeOptional,
|
||||
} from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
@@ -41,6 +46,7 @@ import {
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
@@ -72,6 +78,55 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
type AudioOutputPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "audio_output" }
|
||||
>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
chunkIndex: number;
|
||||
audio: string;
|
||||
format: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function decodeBase64Chunk(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
function buildAudioPlaybackSource(
|
||||
chunks: BufferedAudioChunk[]
|
||||
): AudioPlaybackSource {
|
||||
const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio));
|
||||
const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of decodedChunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const format = chunks[0]?.format ?? "pcm";
|
||||
const mimeType =
|
||||
format === "pcm"
|
||||
? "audio/pcm;rate=24000;bits=16"
|
||||
: format === "mp3"
|
||||
? "audio/mpeg"
|
||||
: `audio/${format}`;
|
||||
|
||||
const bytes = output.slice();
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
type: mimeType,
|
||||
async arrayBuffer() {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
@@ -205,8 +260,10 @@ function SessionProviderInternal({
|
||||
serverId,
|
||||
client,
|
||||
}: SessionProviderClientProps) {
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const queryClient = useQueryClient();
|
||||
const { isConnected } = useHostRuntimeSession(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
@@ -275,16 +332,6 @@ function SessionProviderInternal({
|
||||
(state) => state.sessions[serverId]?.agents
|
||||
);
|
||||
|
||||
// State for voice detection flags (will be set by RealtimeContext)
|
||||
const isDetectingRef = useRef(false);
|
||||
const isSpeakingRef = useRef(false);
|
||||
|
||||
const audioPlayer = useAudioPlayer({
|
||||
isDetecting: () => isDetectingRef.current,
|
||||
isSpeaking: () => isSpeakingRef.current,
|
||||
});
|
||||
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -307,6 +354,10 @@ function SessionProviderInternal({
|
||||
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const revalidationQueuedRef = useRef(false);
|
||||
const wasConnectedRef = useRef(isConnected);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(
|
||||
new Map()
|
||||
);
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
@@ -452,11 +503,6 @@ function SessionProviderInternal({
|
||||
const queue = session?.queuedMessages.get(agent.id);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
console.log(
|
||||
"[Session] Flushing queued message for agent:",
|
||||
agent.id,
|
||||
next.text
|
||||
);
|
||||
if (sendAgentMessageRef.current) {
|
||||
void sendAgentMessageRef.current(agent.id, next.text, next.images);
|
||||
}
|
||||
@@ -570,18 +616,10 @@ function SessionProviderInternal({
|
||||
if (params.reason === "error") {
|
||||
return;
|
||||
}
|
||||
const isActive = appState ? appState === "active" : true;
|
||||
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
|
||||
const isActivelyVisible = getIsAppActivelyVisible(appState);
|
||||
const isAwayFromAgent =
|
||||
!isActivelyVisible || 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;
|
||||
}
|
||||
|
||||
@@ -624,24 +662,55 @@ function SessionProviderInternal({
|
||||
[serverId]
|
||||
);
|
||||
|
||||
// Buffer for streaming audio chunks
|
||||
interface AudioChunk {
|
||||
chunkIndex: number;
|
||||
audio: string; // base64
|
||||
format: string;
|
||||
id: string;
|
||||
}
|
||||
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
|
||||
|
||||
// Initialize session in store
|
||||
useEffect(() => {
|
||||
initializeSession(serverId, client, audioPlayer);
|
||||
}, [serverId, client, audioPlayer, initializeSession]);
|
||||
initializeSession(serverId, client);
|
||||
}, [serverId, client, initializeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSessionClient(serverId, client);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
return voiceRuntime.registerSession({
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType, isLast) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType, isLast);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.abortRequest();
|
||||
},
|
||||
setAssistantAudioPlaying: (isPlaying) => {
|
||||
setIsPlayingAudio(serverId, isPlaying);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
serverId,
|
||||
setIsPlayingAudio,
|
||||
voiceRuntime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRuntime?.updateSessionConnection(serverId, isConnected);
|
||||
}, [isConnected, serverId, voiceRuntime]);
|
||||
|
||||
// If the client drops mid-initialization, clear pending flags
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
@@ -969,6 +1038,14 @@ function SessionProviderInternal({
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
|
||||
// Attention notification stays in React (not extractable to pure reducer)
|
||||
if (event.type === "attention_required") {
|
||||
@@ -1118,13 +1195,6 @@ function SessionProviderInternal({
|
||||
if (message.type !== "agent_permission_request") return;
|
||||
const { agentId, request } = message.payload;
|
||||
|
||||
console.log(
|
||||
"[Session] Permission request:",
|
||||
request.id,
|
||||
"for agent:",
|
||||
agentId
|
||||
);
|
||||
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const key = derivePendingPermissionKey(agentId, request);
|
||||
@@ -1140,13 +1210,6 @@ function SessionProviderInternal({
|
||||
if (message.type !== "agent_permission_resolved") return;
|
||||
const { requestId, agentId } = message.payload;
|
||||
|
||||
console.log(
|
||||
"[Session] Permission resolved:",
|
||||
requestId,
|
||||
"for agent:",
|
||||
agentId
|
||||
);
|
||||
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const derivedKey = `${agentId}:${requestId}`;
|
||||
@@ -1168,92 +1231,70 @@ function SessionProviderInternal({
|
||||
|
||||
const unsubAudioOutput = client.on("audio_output", async (message) => {
|
||||
if (message.type !== "audio_output") return;
|
||||
const data = message.payload;
|
||||
const playbackGroupId = data.groupId ?? data.id;
|
||||
const isFinalChunk = data.isLastChunk ?? true;
|
||||
const chunkIndex = data.chunkIndex ?? 0;
|
||||
if (!voiceAudioEngine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AudioOutputPayload = message.payload;
|
||||
const playbackGroupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
const isFinalChunk = payload.isLastChunk ?? true;
|
||||
|
||||
if (!audioOutputBuffersRef.current.has(playbackGroupId)) {
|
||||
audioOutputBuffersRef.current.set(playbackGroupId, []);
|
||||
}
|
||||
|
||||
const bufferedChunks = audioOutputBuffersRef.current.get(playbackGroupId)!;
|
||||
bufferedChunks.push({
|
||||
chunkIndex,
|
||||
audio: payload.audio,
|
||||
format: payload.format,
|
||||
id: payload.id,
|
||||
});
|
||||
|
||||
activeAudioGroupsRef.current.add(playbackGroupId);
|
||||
setIsPlayingAudio(serverId, true);
|
||||
|
||||
if (!audioChunkBuffersRef.current.has(playbackGroupId)) {
|
||||
audioChunkBuffersRef.current.set(playbackGroupId, []);
|
||||
}
|
||||
|
||||
const buffer = audioChunkBuffersRef.current.get(playbackGroupId)!;
|
||||
buffer.push({
|
||||
chunkIndex,
|
||||
audio: data.audio,
|
||||
format: data.format,
|
||||
id: data.id,
|
||||
});
|
||||
|
||||
if (!isFinalChunk) {
|
||||
return;
|
||||
}
|
||||
buffer.sort((a, b) => a.chunkIndex - b.chunkIndex);
|
||||
|
||||
let playbackFailed = false;
|
||||
const chunkIds = buffer.map((chunk) => chunk.id);
|
||||
|
||||
const confirmAudioPlayed = (ids: string[]) => {
|
||||
if (!client) {
|
||||
console.warn("[Session] audio_played skipped: daemon unavailable");
|
||||
return;
|
||||
}
|
||||
ids.forEach((chunkId) => {
|
||||
void client.audioPlayed(chunkId).catch((error) => {
|
||||
console.warn("[Session] Failed to confirm audio playback:", error);
|
||||
});
|
||||
});
|
||||
bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex);
|
||||
const chunkIds = bufferedChunks.map((chunk) => chunk.id);
|
||||
const shouldPlay =
|
||||
!payload.isVoiceMode ||
|
||||
(voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false);
|
||||
const audioBlob = buildAudioPlaybackSource(bufferedChunks);
|
||||
const confirmAudioPlayed = async () => {
|
||||
await Promise.all(
|
||||
chunkIds.map((chunkId) =>
|
||||
client.audioPlayed(chunkId).catch((error) => {
|
||||
console.warn("[Session] Failed to confirm audio playback:", error);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
let startedVoicePlayback = false;
|
||||
try {
|
||||
const mimeType =
|
||||
data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`;
|
||||
|
||||
const decodedChunks: Uint8Array[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const chunk of buffer) {
|
||||
const binaryString = atob(chunk.audio);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
if (shouldPlay) {
|
||||
if (payload.isVoiceMode) {
|
||||
startedVoicePlayback = true;
|
||||
voiceRuntime?.onAssistantAudioStarted(serverId);
|
||||
}
|
||||
decodedChunks.push(bytes);
|
||||
totalSize += bytes.length;
|
||||
await voiceAudioEngine.play(audioBlob);
|
||||
}
|
||||
|
||||
const concatenatedBytes = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of decodedChunks) {
|
||||
concatenatedBytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const audioBlob = {
|
||||
type: mimeType,
|
||||
size: totalSize,
|
||||
arrayBuffer: async () => {
|
||||
return concatenatedBytes.buffer;
|
||||
},
|
||||
} as Blob;
|
||||
|
||||
await audioPlayer.play(audioBlob);
|
||||
|
||||
confirmAudioPlayed(chunkIds);
|
||||
} catch (error: any) {
|
||||
playbackFailed = true;
|
||||
await confirmAudioPlayed();
|
||||
} catch (error) {
|
||||
console.error("[Session] Audio playback error:", error);
|
||||
|
||||
confirmAudioPlayed(chunkIds);
|
||||
await confirmAudioPlayed();
|
||||
} finally {
|
||||
audioChunkBuffersRef.current.delete(playbackGroupId);
|
||||
audioOutputBuffersRef.current.delete(playbackGroupId);
|
||||
activeAudioGroupsRef.current.delete(playbackGroupId);
|
||||
setIsPlayingAudio(serverId, activeAudioGroupsRef.current.size > 0);
|
||||
|
||||
if (activeAudioGroupsRef.current.size === 0) {
|
||||
setIsPlayingAudio(serverId, false);
|
||||
if (startedVoicePlayback) {
|
||||
voiceRuntime?.onAssistantAudioFinished(serverId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1261,7 +1302,6 @@ function SessionProviderInternal({
|
||||
const unsubActivity = client.on("activity_log", (message) => {
|
||||
if (message.type !== "activity_log") return;
|
||||
const data = message.payload;
|
||||
|
||||
if (data.type === "system" && data.content.includes("Transcribing")) {
|
||||
return;
|
||||
}
|
||||
@@ -1381,13 +1421,12 @@ function SessionProviderInternal({
|
||||
if (message.type !== "transcription_result") return;
|
||||
|
||||
const transcriptText = message.payload.text.trim();
|
||||
|
||||
if (!transcriptText) {
|
||||
} else {
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(serverId, false);
|
||||
setCurrentAssistantMessage(serverId, "");
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentAssistantMessage(serverId, "");
|
||||
});
|
||||
|
||||
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
|
||||
@@ -1395,7 +1434,6 @@ function SessionProviderInternal({
|
||||
return;
|
||||
}
|
||||
const { agentId } = message.payload;
|
||||
console.log("[Session] Agent deleted:", agentId);
|
||||
deletePendingAgentUpdate(serverId, agentId);
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId });
|
||||
|
||||
@@ -1471,7 +1509,6 @@ function SessionProviderInternal({
|
||||
return;
|
||||
}
|
||||
const { agentId, archivedAt } = message.payload;
|
||||
console.log("[Session] Agent archived:", agentId);
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId });
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
@@ -1505,7 +1542,6 @@ function SessionProviderInternal({
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
audioPlayer,
|
||||
queryClient,
|
||||
serverId,
|
||||
setIsPlayingAudio,
|
||||
@@ -1528,6 +1564,8 @@ function SessionProviderInternal({
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
applyTimelineResponse,
|
||||
voiceRuntime,
|
||||
voiceAudioEngine,
|
||||
]);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
@@ -1654,11 +1692,6 @@ function SessionProviderInternal({
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
}) => {
|
||||
console.log(
|
||||
"[Session] createAgent called with images:",
|
||||
images?.length ?? 0,
|
||||
images
|
||||
);
|
||||
if (!client) {
|
||||
console.warn("[Session] createAgent skipped: daemon unavailable");
|
||||
return;
|
||||
@@ -1667,14 +1700,6 @@ function SessionProviderInternal({
|
||||
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
|
||||
try {
|
||||
imagesData = await encodeImages(images);
|
||||
console.log(
|
||||
"[Session] encodeImages result:",
|
||||
imagesData?.length ?? 0,
|
||||
imagesData?.map((img) => ({
|
||||
dataLength: img.data?.length ?? 0,
|
||||
mimeType: img.mimeType,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Session] Failed to prepare images for agent creation:",
|
||||
@@ -1749,20 +1774,12 @@ function SessionProviderInternal({
|
||||
[client]
|
||||
);
|
||||
|
||||
const setVoiceDetectionFlags = useCallback(
|
||||
(isDetecting: boolean, isSpeaking: boolean) => {
|
||||
isDetectingRef.current = isDetecting;
|
||||
isSpeakingRef.current = isSpeaking;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSession(serverId);
|
||||
};
|
||||
}, [serverId, clearSession]);
|
||||
}, [clearSession, serverId]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,106 @@
|
||||
import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||
import type { SessionState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
createVoiceRuntime,
|
||||
type VoiceRuntime,
|
||||
type VoiceRuntimeSnapshot,
|
||||
type VoiceRuntimeTelemetrySnapshot,
|
||||
} from "@/voice/voice-runtime";
|
||||
|
||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||
interface VoiceContextValue {
|
||||
isVoiceMode: boolean;
|
||||
isVoiceSwitching: boolean;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isDetecting: boolean;
|
||||
isSpeaking: boolean;
|
||||
segmentDuration: number;
|
||||
interface VoiceContextValue extends VoiceRuntimeSnapshot {
|
||||
startVoice: (serverId: string, agentId: string) => Promise<void>;
|
||||
stopVoice: () => Promise<void>;
|
||||
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
|
||||
toggleMute: () => void;
|
||||
activeServerId: string | null;
|
||||
activeAgentId: string | null;
|
||||
}
|
||||
|
||||
const VoiceContext = createContext<VoiceContextValue | null>(null);
|
||||
const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
phase: "disabled",
|
||||
isVoiceMode: false,
|
||||
isVoiceSwitching: false,
|
||||
isMuted: false,
|
||||
activeServerId: null,
|
||||
activeAgentId: null,
|
||||
};
|
||||
|
||||
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||
volume: 0,
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
segmentDuration: 0,
|
||||
};
|
||||
|
||||
const VoiceRuntimeContext = createContext<VoiceRuntime | null>(null);
|
||||
const VoiceAudioEngineContext = createContext<AudioEngine | null>(null);
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
const getEmptySnapshot = () => EMPTY_SNAPSHOT;
|
||||
const getEmptyTelemetry = () => EMPTY_TELEMETRY;
|
||||
|
||||
export function useVoice() {
|
||||
const context = useContext(VoiceContext);
|
||||
if (!context) {
|
||||
const value = useVoiceOptional();
|
||||
if (!value) {
|
||||
throw new Error("useVoice must be used within VoiceProvider");
|
||||
}
|
||||
return context;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useVoiceOptional(): VoiceContextValue | null {
|
||||
return useContext(VoiceContext);
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribe : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot
|
||||
);
|
||||
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice,
|
||||
stopVoice: runtime.stopVoice,
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||
toggleMute: runtime.toggleMute,
|
||||
};
|
||||
}
|
||||
|
||||
export function useVoiceTelemetry() {
|
||||
const telemetry = useVoiceTelemetryOptional();
|
||||
if (!telemetry) {
|
||||
throw new Error("useVoiceTelemetry must be used within VoiceProvider");
|
||||
}
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribeTelemetry : noopSubscribe,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry
|
||||
);
|
||||
|
||||
return runtime ? snapshot : null;
|
||||
}
|
||||
|
||||
export function useVoiceRuntimeOptional(): VoiceRuntime | null {
|
||||
return useContext(VoiceRuntimeContext);
|
||||
}
|
||||
|
||||
export function useVoiceAudioEngineOptional(): AudioEngine | null {
|
||||
return useContext(VoiceAudioEngineContext);
|
||||
}
|
||||
|
||||
interface VoiceProviderProps {
|
||||
@@ -43,405 +108,55 @@ interface VoiceProviderProps {
|
||||
}
|
||||
|
||||
export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
const getSession = useSessionStore((state) => state.getSession);
|
||||
const [activeServerId, setActiveServerId] = useState<string | null>(null);
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession(
|
||||
activeServerId ?? ""
|
||||
);
|
||||
const activeSession = useSessionStore(
|
||||
useCallback(
|
||||
(state: ReturnType<typeof useSessionStore.getState>) => {
|
||||
if (!activeServerId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[activeServerId] ?? null;
|
||||
const engineRef = useRef<AudioEngine | null>(null);
|
||||
const runtimeRef = useRef<VoiceRuntime | null>(null);
|
||||
|
||||
if (!engineRef.current) {
|
||||
let runtime: VoiceRuntime | null = null;
|
||||
const engine = createAudioEngine({
|
||||
onCaptureData: (pcm) => {
|
||||
runtime?.handleCapturePcm(pcm);
|
||||
},
|
||||
[activeServerId]
|
||||
)
|
||||
);
|
||||
const realtimeSessionRef = useRef<SessionState | null>(null);
|
||||
const [isVoiceMode, setIsVoiceMode] = useState(false);
|
||||
const [isVoiceSwitching, setIsVoiceSwitching] = useState(false);
|
||||
const bargeInPlaybackStopRef = useRef<number | null>(null);
|
||||
const wasVoiceSocketConnectedRef = useRef(false);
|
||||
const lastVoiceModeSyncedClientRef = useRef<SessionState["client"] | null>(null);
|
||||
const voiceTransportReadyRef = useRef(false);
|
||||
const voiceResyncInFlightRef = useRef(false);
|
||||
const silenceGraceStartMsRef = useRef<number | null>(null);
|
||||
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const speechInterruptStartMsRef = useRef<number | null>(null);
|
||||
const speechStartInterruptSentRef = useRef(false);
|
||||
const isVoiceModeRef = useRef(false);
|
||||
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
});
|
||||
onVolumeLevel: (level) => {
|
||||
runtime?.handleCaptureVolume(level);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[VoiceEngine] Capture error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
|
||||
const timer = speechInterruptTimerRef.current;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
speechInterruptTimerRef.current = null;
|
||||
const startedAt = speechInterruptStartMsRef.current;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (startedAt !== null) {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", {
|
||||
reason,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
} else {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
|
||||
}
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = null;
|
||||
}, []);
|
||||
runtime = createVoiceRuntime({
|
||||
engine,
|
||||
getServerInfo: (serverId) =>
|
||||
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
|
||||
activateKeepAwake: async (tag) => {
|
||||
await activateKeepAwakeAsync(tag);
|
||||
},
|
||||
deactivateKeepAwake: async (tag) => {
|
||||
await deactivateKeepAwake(tag);
|
||||
},
|
||||
});
|
||||
|
||||
const interruptActiveVoiceTurn = useCallback((source: string) => {
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
engineRef.current = engine;
|
||||
runtimeRef.current = runtime;
|
||||
}
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
if (bargeInPlaybackStopRef.current === null) {
|
||||
bargeInPlaybackStopRef.current = Date.now();
|
||||
}
|
||||
sessionAudioPlayer.stop();
|
||||
}
|
||||
|
||||
try {
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Voice] Sent abort_request before streaming audio", { source });
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const realtimeAudio = useSpeechmaticsAudio({
|
||||
onSpeechStart: () => {
|
||||
console.log("[Voice] Segment started (speech confirmed)");
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
const silenceMs =
|
||||
silenceGraceStartMsRef.current === null
|
||||
? null
|
||||
: Date.now() - silenceGraceStartMsRef.current;
|
||||
if (silenceMs === null) {
|
||||
console.log("[Voice] Segment finalized");
|
||||
} else {
|
||||
console.log("[Voice] Segment finalized", { silenceMs });
|
||||
}
|
||||
silenceGraceStartMsRef.current = null;
|
||||
clearSpeechStartInterruptTimer("speech ended");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
},
|
||||
onAudioSegment: ({ audioData, isLast }) => {
|
||||
if (!voiceTransportReadyRef.current) {
|
||||
console.log("[Voice] Skipping audio segment: voice transport not ready");
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
"[Voice] Sending audio segment, length:",
|
||||
audioData.length,
|
||||
"isLast:",
|
||||
isLast
|
||||
);
|
||||
|
||||
// Send audio segment to server (realtime always goes to orchestrator)
|
||||
const session = realtimeSessionRef.current;
|
||||
try {
|
||||
if (session?.client) {
|
||||
void session.client
|
||||
.sendVoiceAudioChunk(
|
||||
audioData,
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
isLast
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[Voice] Failed to send audio segment:", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send audio segment:", error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[Voice] Audio error:", error);
|
||||
const session = realtimeSessionRef.current;
|
||||
if (session?.client) {
|
||||
// Send error through websocket instead of directly manipulating messages
|
||||
console.error("[Voice] Cannot handle error - setMessages not available from SessionState");
|
||||
}
|
||||
},
|
||||
volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold,
|
||||
confirmedDropGracePeriod: REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs,
|
||||
silenceDuration: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs,
|
||||
speechConfirmationDuration: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs,
|
||||
detectionGracePeriod: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
realtimeSessionRef.current = activeSession;
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
isVoiceModeRef.current = isVoiceMode;
|
||||
}, [isVoiceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceMode) {
|
||||
clearSpeechStartInterruptTimer("voice mode disabled");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (realtimeAudio.isSpeaking) {
|
||||
if (
|
||||
speechStartInterruptSentRef.current ||
|
||||
speechInterruptTimerRef.current !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = Date.now();
|
||||
speechInterruptTimerRef.current = setTimeout(() => {
|
||||
speechInterruptTimerRef.current = null;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (
|
||||
!isVoiceModeRef.current ||
|
||||
!vadStateRef.current.isSpeaking ||
|
||||
speechStartInterruptSentRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechStartInterruptSentRef.current = true;
|
||||
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
|
||||
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
|
||||
});
|
||||
interruptActiveVoiceTurn("speech_start_grace_elapsed");
|
||||
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSpeechStartInterruptTimer("speech stopped before grace");
|
||||
}, [
|
||||
clearSpeechStartInterruptTimer,
|
||||
interruptActiveVoiceTurn,
|
||||
isVoiceMode,
|
||||
realtimeAudio.isSpeaking,
|
||||
]);
|
||||
const engine = engineRef.current!;
|
||||
const runtime = runtimeRef.current!;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSpeechStartInterruptTimer("voice provider unmounted");
|
||||
};
|
||||
}, [clearSpeechStartInterruptTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = {
|
||||
isDetecting: realtimeAudio.isDetecting,
|
||||
isSpeaking: realtimeAudio.isSpeaking,
|
||||
};
|
||||
const prev = vadStateRef.current;
|
||||
|
||||
if (!isVoiceMode) {
|
||||
silenceGraceStartMsRef.current = null;
|
||||
vadStateRef.current = next;
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmed speech dropped below threshold: grace window starts.
|
||||
if (prev.isSpeaking && !next.isSpeaking && next.isDetecting) {
|
||||
silenceGraceStartMsRef.current = Date.now();
|
||||
console.log("[Voice] Grace started (speech dropped below threshold)");
|
||||
}
|
||||
|
||||
// User resumed speaking before grace timeout elapsed.
|
||||
if (!prev.isSpeaking && next.isSpeaking && silenceGraceStartMsRef.current !== null) {
|
||||
const resumedAfterMs = Date.now() - silenceGraceStartMsRef.current;
|
||||
console.log("[Voice] Speech resumed during grace", { resumedAfterMs });
|
||||
silenceGraceStartMsRef.current = null;
|
||||
}
|
||||
|
||||
// Fully idle (neither detecting nor speaking).
|
||||
if (!next.isDetecting && !next.isSpeaking) {
|
||||
silenceGraceStartMsRef.current = null;
|
||||
speechStartInterruptSentRef.current = false;
|
||||
}
|
||||
|
||||
vadStateRef.current = next;
|
||||
}, [isVoiceMode, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]);
|
||||
|
||||
useEffect(() => {
|
||||
const connected = activeRuntimeConnected;
|
||||
const client = runtimeClient;
|
||||
if (!connected) {
|
||||
voiceTransportReadyRef.current = false;
|
||||
}
|
||||
|
||||
if (!isVoiceMode || !activeServerId || !activeAgentId || !client) {
|
||||
wasVoiceSocketConnectedRef.current = connected;
|
||||
if (!isVoiceMode) {
|
||||
lastVoiceModeSyncedClientRef.current = null;
|
||||
}
|
||||
voiceTransportReadyRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionRecovered = connected && !wasVoiceSocketConnectedRef.current;
|
||||
const clientChanged = lastVoiceModeSyncedClientRef.current !== client;
|
||||
if (connected && (connectionRecovered || clientChanged)) {
|
||||
if (!voiceResyncInFlightRef.current) {
|
||||
voiceResyncInFlightRef.current = true;
|
||||
voiceTransportReadyRef.current = false;
|
||||
setIsVoiceSwitching(true);
|
||||
void client.setVoiceMode(true, activeAgentId).then(
|
||||
() => {
|
||||
console.log("[Voice] Re-synced voice mode after reconnect");
|
||||
lastVoiceModeSyncedClientRef.current = client;
|
||||
voiceTransportReadyRef.current = true;
|
||||
},
|
||||
(error) => {
|
||||
console.error("[Voice] Failed to re-sync voice mode:", error);
|
||||
}
|
||||
).finally(() => {
|
||||
voiceResyncInFlightRef.current = false;
|
||||
setIsVoiceSwitching(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
wasVoiceSocketConnectedRef.current = connected;
|
||||
}, [activeAgentId, activeRuntimeConnected, activeServerId, isVoiceMode, runtimeClient]);
|
||||
|
||||
const isPlayingAudio = activeSession?.isPlayingAudio ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) {
|
||||
const latencyMs = Date.now() - bargeInPlaybackStopRef.current;
|
||||
console.log("[Telemetry] barge_in.playback_stop_latency", {
|
||||
latencyMs,
|
||||
startedAt: new Date(bargeInPlaybackStopRef.current).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
void runtime.destroy().catch((error) => {
|
||||
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
|
||||
});
|
||||
bargeInPlaybackStopRef.current = null;
|
||||
}
|
||||
}, [isPlayingAudio]);
|
||||
|
||||
const startVoice = useCallback(
|
||||
async (serverId: string, agentId: string) => {
|
||||
const session = getSession(serverId) ?? null;
|
||||
if (!session) {
|
||||
throw new Error(`Host ${serverId} is not connected`);
|
||||
}
|
||||
const unavailableMessage = resolveVoiceUnavailableMessage({
|
||||
serverInfo: session.serverInfo,
|
||||
mode: "voice",
|
||||
});
|
||||
if (unavailableMessage) {
|
||||
throw new Error(unavailableMessage);
|
||||
}
|
||||
|
||||
setIsVoiceSwitching(true);
|
||||
voiceTransportReadyRef.current = false;
|
||||
try {
|
||||
const previousSession = realtimeSessionRef.current;
|
||||
if (
|
||||
isVoiceMode &&
|
||||
previousSession?.client &&
|
||||
(activeServerId !== serverId || activeAgentId !== agentId)
|
||||
) {
|
||||
await previousSession.client.setVoiceMode(false);
|
||||
}
|
||||
|
||||
realtimeSessionRef.current = session;
|
||||
setActiveServerId(serverId);
|
||||
setActiveAgentId(agentId);
|
||||
await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) => {
|
||||
console.warn("[Voice] Failed to activate keep-awake:", error);
|
||||
});
|
||||
if (session?.client) {
|
||||
await session.client.setVoiceMode(true, agentId);
|
||||
} else {
|
||||
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
|
||||
}
|
||||
await session.audioPlayer?.warmup?.();
|
||||
await realtimeAudio.start();
|
||||
voiceTransportReadyRef.current = true;
|
||||
setIsVoiceMode(true);
|
||||
lastVoiceModeSyncedClientRef.current = session.client;
|
||||
console.log("[Voice] Mode enabled");
|
||||
} catch (error: any) {
|
||||
console.error("[Voice] Failed to start:", error);
|
||||
await realtimeAudio.stop().catch(() => undefined);
|
||||
setActiveServerId((current) => (current === serverId ? null : current));
|
||||
setActiveAgentId((current) => (current === agentId ? null : current));
|
||||
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsVoiceSwitching(false);
|
||||
}
|
||||
},
|
||||
[activeAgentId, activeServerId, getSession, isVoiceMode, realtimeAudio]
|
||||
);
|
||||
|
||||
const stopVoice = useCallback(async () => {
|
||||
setIsVoiceSwitching(true);
|
||||
voiceTransportReadyRef.current = false;
|
||||
try {
|
||||
const session = realtimeSessionRef.current;
|
||||
session?.audioPlayer?.stop();
|
||||
if (session?.client) {
|
||||
await session.client.setVoiceMode(false);
|
||||
lastVoiceModeSyncedClientRef.current = session.client;
|
||||
} else {
|
||||
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
|
||||
}
|
||||
await realtimeAudio.stop();
|
||||
setIsVoiceMode(false);
|
||||
setActiveServerId(null);
|
||||
setActiveAgentId(null);
|
||||
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||
console.log("[Voice] Mode disabled");
|
||||
} catch (error: any) {
|
||||
console.error("[Voice] Failed to stop:", error);
|
||||
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsVoiceSwitching(false);
|
||||
}
|
||||
}, [realtimeAudio]);
|
||||
|
||||
const isVoiceModeForAgent = useCallback(
|
||||
(serverId: string, agentId: string) =>
|
||||
isVoiceMode && activeServerId === serverId && activeAgentId === agentId,
|
||||
[activeAgentId, activeServerId, isVoiceMode]
|
||||
);
|
||||
|
||||
const value: VoiceContextValue = {
|
||||
isVoiceMode,
|
||||
isVoiceSwitching,
|
||||
volume: realtimeAudio.volume,
|
||||
isMuted: realtimeAudio.isMuted,
|
||||
isDetecting: realtimeAudio.isDetecting,
|
||||
isSpeaking: realtimeAudio.isSpeaking,
|
||||
segmentDuration: realtimeAudio.segmentDuration,
|
||||
startVoice,
|
||||
stopVoice,
|
||||
isVoiceModeForAgent,
|
||||
toggleMute: realtimeAudio.toggleMute,
|
||||
activeServerId,
|
||||
activeAgentId,
|
||||
};
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={value}>
|
||||
{children}
|
||||
</VoiceContext.Provider>
|
||||
<VoiceAudioEngineContext.Provider value={engine}>
|
||||
<VoiceRuntimeContext.Provider value={runtime}>
|
||||
{children}
|
||||
</VoiceRuntimeContext.Provider>
|
||||
</VoiceAudioEngineContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,335 +1,323 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ArrowUpRight, Play, Pause, RotateCw, Terminal, Copy, FileText, Smartphone } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { settingsStyles } from '@/styles/settings'
|
||||
import {
|
||||
formatVersionWithPrefix,
|
||||
isVersionMismatch,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
ArrowUpRight,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCw,
|
||||
Terminal,
|
||||
Copy,
|
||||
FileText,
|
||||
Smartphone,
|
||||
} from 'lucide-react-native'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppSettings } from '@/hooks/use-settings'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
|
||||
import {
|
||||
getCliSymlinkInstructions,
|
||||
getManagedDaemonLogs,
|
||||
getManagedDaemonPairing,
|
||||
getManagedDaemonStatus,
|
||||
installManagedCliShim,
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
stopManagedDaemon,
|
||||
uninstallManagedCliShim,
|
||||
type CliSymlinkInstructions,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
type ManagedDaemonStatus,
|
||||
type CliManualInstructions,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
} from '@/desktop/managed-runtime/managed-runtime'
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null;
|
||||
appVersion: string | null
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseManagedDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
||||
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
|
||||
null
|
||||
);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const { theme } = useUnistyles()
|
||||
const showSection = shouldUseManagedDesktopDaemon()
|
||||
const { settings, updateSettings } = useAppSettings()
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
|
||||
const [statusError, setStatusError] = useState<string | null>(null)
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
|
||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
|
||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null)
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
|
||||
|
||||
const loadManagedStatus = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setManagedStatus(status);
|
||||
setManagedLogs(logs);
|
||||
setStatusError(null);
|
||||
setManagedStatus(status)
|
||||
setManagedLogs(logs)
|
||||
setStatusError(null)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusError(message);
|
||||
});
|
||||
}, [showSection]);
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setStatusError(message)
|
||||
})
|
||||
}, [showSection])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
void loadManagedStatus();
|
||||
return undefined;
|
||||
void loadManagedStatus()
|
||||
return undefined
|
||||
}, [loadManagedStatus, showSection])
|
||||
);
|
||||
)
|
||||
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
|
||||
const daemonStatusStateText =
|
||||
statusError ??
|
||||
(managedStatus?.daemonRunning
|
||||
? managedStatus?.daemonStatus ?? "running"
|
||||
: "not running");
|
||||
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
|
||||
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
|
||||
const daemonActionMessage = managedStatus?.daemonRunning
|
||||
? "Restarts the built-in daemon."
|
||||
: isDaemonManagementPaused
|
||||
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
|
||||
: "Starts the built-in daemon.";
|
||||
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
|
||||
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
|
||||
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
|
||||
const daemonActionMessage =
|
||||
managedStatus?.status === 'running'
|
||||
? 'Restarts the built-in daemon.'
|
||||
: 'Starts the built-in daemon.'
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (isRestartingDaemon) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: daemonActionLabel,
|
||||
message: managedStatus?.daemonRunning
|
||||
? "This will restart the built-in daemon. The app will reconnect automatically."
|
||||
: "This will start the built-in daemon.",
|
||||
message:
|
||||
managedStatus?.status === 'running'
|
||||
? 'This will restart the built-in daemon. The app will reconnect automatically.'
|
||||
: 'This will start the built-in daemon.',
|
||||
confirmLabel: daemonActionLabel,
|
||||
cancelLabel: "Cancel",
|
||||
cancelLabel: 'Cancel',
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestartingDaemon(true);
|
||||
setStatusMessage(null);
|
||||
setIsRestartingDaemon(true)
|
||||
setStatusMessage(null)
|
||||
|
||||
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
|
||||
const action =
|
||||
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
|
||||
|
||||
void action()
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setManagedStatus(status)
|
||||
setStatusMessage(
|
||||
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
|
||||
);
|
||||
return loadManagedStatus();
|
||||
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
|
||||
)
|
||||
return loadManagedStatus()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to change managed daemon state", error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
|
||||
console.error('[Settings] Failed to change managed daemon state', error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRestartingDaemon(false);
|
||||
});
|
||||
setIsRestartingDaemon(false)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open managed daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
|
||||
console.error('[Settings] Failed to open managed daemon action confirmation', error)
|
||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||
})
|
||||
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
setIsUpdatingDaemonManagement(true)
|
||||
setStatusMessage(null)
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => {
|
||||
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
|
||||
setStatusMessage('Built-in daemon management resumed.')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
console.error('[Settings] Failed to update built-in daemon management', error)
|
||||
Alert.alert('Error', 'Unable to update built-in daemon management.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
});
|
||||
return;
|
||||
setIsUpdatingDaemonManagement(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Pause built-in daemon",
|
||||
title: 'Pause built-in daemon',
|
||||
message:
|
||||
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||
confirmLabel: "Pause and stop",
|
||||
cancelLabel: "Cancel",
|
||||
'This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.',
|
||||
confirmLabel: 'Pause and stop',
|
||||
cancelLabel: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
setIsUpdatingDaemonManagement(true)
|
||||
setStatusMessage(null)
|
||||
|
||||
const stopPromise = managedStatus?.daemonRunning
|
||||
? stopManagedDaemon()
|
||||
: Promise.resolve(managedStatus ?? null);
|
||||
const stopPromise =
|
||||
managedStatus?.status === 'running'
|
||||
? stopManagedDaemon()
|
||||
: Promise.resolve(managedStatus ?? null)
|
||||
|
||||
void stopPromise
|
||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||
.then(() => loadManagedStatus())
|
||||
.then(() => {
|
||||
setStatusMessage(
|
||||
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
|
||||
);
|
||||
setStatusMessage('Built-in daemon paused and stopped.')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to pause built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to pause built-in daemon management.");
|
||||
console.error('[Settings] Failed to pause built-in daemon management', error)
|
||||
Alert.alert('Error', 'Unable to pause built-in daemon management.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
});
|
||||
setIsUpdatingDaemonManagement(false)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
console.error('[Settings] Failed to open built-in daemon pause confirmation', error)
|
||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||
})
|
||||
}, [
|
||||
isUpdatingDaemonManagement,
|
||||
loadManagedStatus,
|
||||
managedStatus,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
]);
|
||||
])
|
||||
|
||||
const handleToggleCliShim = useCallback(() => {
|
||||
if (!showSection || isInstallingCli) {
|
||||
return;
|
||||
const handleOpenCliSymlinkInstructions = useCallback(() => {
|
||||
if (!showSection || isLoadingCliSymlinkInstructions) {
|
||||
return
|
||||
}
|
||||
setIsInstallingCli(true);
|
||||
const isInstalling = !managedStatus?.cliShimPath;
|
||||
setCliStatusMessage(
|
||||
isInstalling
|
||||
? "A permissions popup may appear while Paseo installs the CLI globally."
|
||||
: null
|
||||
);
|
||||
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
|
||||
void action()
|
||||
.then((result) => {
|
||||
setCliStatusMessage(result.message);
|
||||
if (result.manualInstructions) {
|
||||
setCliInstallInstructions(result.manualInstructions);
|
||||
setIsCliInstallModalOpen(true);
|
||||
} else {
|
||||
setCliInstallInstructions(null);
|
||||
setIsCliInstallModalOpen(false);
|
||||
}
|
||||
return loadManagedStatus();
|
||||
setIsLoadingCliSymlinkInstructions(true)
|
||||
setCliStatusMessage(null)
|
||||
void getCliSymlinkInstructions()
|
||||
.then((instructions) => {
|
||||
setCliSymlinkInstructions(instructions)
|
||||
setIsCliSymlinkModalOpen(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusMessage(`CLI install failed: ${message}`);
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingCli(false);
|
||||
});
|
||||
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
|
||||
setIsLoadingCliSymlinkInstructions(false)
|
||||
})
|
||||
}, [isLoadingCliSymlinkInstructions, showSection])
|
||||
|
||||
const handleCopyCliInstallCommands = useCallback(() => {
|
||||
if (!cliInstallInstructions?.commands) {
|
||||
return;
|
||||
const handleCopyCliSymlinkCommands = useCallback(() => {
|
||||
if (!cliSymlinkInstructions?.commands) {
|
||||
return
|
||||
}
|
||||
void Clipboard.setStringAsync(cliInstallInstructions.commands)
|
||||
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "CLI install commands copied.");
|
||||
Alert.alert('Copied', 'CLI symlink commands copied.')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy CLI install commands", error);
|
||||
Alert.alert("Error", "Unable to copy CLI install commands.");
|
||||
});
|
||||
}, [cliInstallInstructions?.commands]);
|
||||
console.error('[Settings] Failed to copy CLI symlink commands', error)
|
||||
Alert.alert('Error', 'Unable to copy CLI symlink commands.')
|
||||
})
|
||||
}, [cliSymlinkInstructions?.commands])
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
||||
const logPath = managedLogs?.logPath
|
||||
if (!logPath) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Log path copied.");
|
||||
Alert.alert('Copied', 'Log path copied.')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
});
|
||||
}, [managedLogs?.logPath, managedStatus?.logPath]);
|
||||
console.error('[Settings] Failed to copy log path', error)
|
||||
Alert.alert('Error', 'Unable to copy log path.')
|
||||
})
|
||||
}, [managedLogs?.logPath])
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!managedLogs) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setIsLogsModalOpen(true);
|
||||
}, [managedLogs]);
|
||||
setIsLogsModalOpen(true)
|
||||
}, [managedLogs])
|
||||
|
||||
const handleOpenPairingModal = useCallback(() => {
|
||||
if (isLoadingPairing) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
setIsPairingModalOpen(true);
|
||||
setIsLoadingPairing(true);
|
||||
setPairingStatusMessage(null);
|
||||
setIsPairingModalOpen(true)
|
||||
setIsLoadingPairing(true)
|
||||
setPairingStatusMessage(null)
|
||||
|
||||
void getManagedDaemonPairing()
|
||||
.then((pairing) => {
|
||||
setPairingOffer(pairing);
|
||||
setPairingOffer(pairing)
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
setPairingStatusMessage("Relay pairing is not available.");
|
||||
setPairingStatusMessage('Relay pairing is not available.')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPairingOffer(null);
|
||||
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setPairingOffer(null)
|
||||
setPairingStatusMessage(`Unable to load pairing offer: ${message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingPairing(false);
|
||||
});
|
||||
}, [isLoadingPairing]);
|
||||
setIsLoadingPairing(false)
|
||||
})
|
||||
}, [isLoadingPairing])
|
||||
|
||||
const handleCopyPairingLink = useCallback(() => {
|
||||
if (!pairingOffer?.url) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
void Clipboard.setStringAsync(pairingOffer.url)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Pairing link copied.");
|
||||
Alert.alert('Copied', 'Pairing link copied.')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy pairing link", error);
|
||||
Alert.alert("Error", "Unable to copy pairing link.");
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
console.error('[Settings] Failed to copy pairing link', error)
|
||||
Alert.alert('Error', 'Unable to copy pairing link.')
|
||||
})
|
||||
}, [pairingOffer?.url])
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -364,35 +352,37 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
|
||||
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
|
||||
? 'Paused. The built-in daemon stays stopped until you start it again.'
|
||||
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={isDaemonManagementPaused
|
||||
? <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
: <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
? 'Resuming...'
|
||||
: 'Pausing...'
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
? 'Resume'
|
||||
: 'Pause'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? (
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
) : null}
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -402,46 +392,41 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.daemonRunning
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
? managedStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Adds the `paseo` command to your terminal.
|
||||
</Text>
|
||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleToggleCliShim}
|
||||
disabled={isInstallingCli}
|
||||
onPress={handleOpenCliSymlinkInstructions}
|
||||
disabled={isLoadingCliSymlinkInstructions}
|
||||
>
|
||||
{isInstallingCli
|
||||
? "Working..."
|
||||
: managedStatus?.cliShimPath
|
||||
? "Uninstall CLI"
|
||||
: "Install CLI"}
|
||||
{isLoadingCliSymlinkInstructions ? 'Loading...' : 'Show instructions'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Log path unavailable."}
|
||||
</Text>
|
||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||
<Button variant="outline" size="sm" leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleCopyLogPath}>
|
||||
{managedLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogPath}
|
||||
>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -459,11 +444,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Connect your phone to this computer.
|
||||
</Text>
|
||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleOpenPairingModal}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenPairingModal}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
@@ -479,27 +467,26 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliInstallModalOpen}
|
||||
onClose={() => setIsCliInstallModalOpen(false)}
|
||||
title="Install CLI manually"
|
||||
testID="managed-daemon-cli-install-dialog"
|
||||
visible={isCliSymlinkModalOpen}
|
||||
onClose={() => setIsCliSymlinkModalOpen(false)}
|
||||
title="Add paseo to your shell"
|
||||
testID="managed-daemon-cli-symlink-dialog"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
A permissions popup should appear when Paseo installs the CLI globally. If it does not
|
||||
complete, open a terminal and run the commands below.
|
||||
Paseo does not add the command for you. Run the command below in your terminal.
|
||||
</Text>
|
||||
{cliInstallInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
|
||||
{cliSymlinkInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeBlock} selectable>
|
||||
{cliInstallInstructions?.commands ?? ""}
|
||||
{cliSymlinkInstructions?.commands ?? ''}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliInstallCommands}>
|
||||
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
|
||||
Copy commands
|
||||
</Button>
|
||||
</View>
|
||||
@@ -525,71 +512,67 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
title="Daemon logs"
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={["70%", "92%"]}
|
||||
snapPoints={['70%', '92%']}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Log path unavailable."}
|
||||
</Text>
|
||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
|
||||
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
pairingOffer: ManagedPairingOffer | null;
|
||||
statusMessage: string | null;
|
||||
onCopyLink: () => void;
|
||||
isLoading: boolean
|
||||
pairingOffer: ManagedPairingOffer | null
|
||||
statusMessage: string | null
|
||||
onCopyLink: () => void
|
||||
}) {
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [qrError, setQrError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cancelled = false
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
setQrDataUrl(null)
|
||||
setQrError(null)
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
setQrError(null);
|
||||
setQrDataUrl(null);
|
||||
setQrError(null)
|
||||
setQrDataUrl(null)
|
||||
|
||||
void QRCode.toDataURL(pairingOffer.url, {
|
||||
errorCorrectionLevel: "M",
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 320,
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setQrDataUrl(dataUrl);
|
||||
setQrDataUrl(dataUrl)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setQrError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
setQrError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pairingOffer?.url]);
|
||||
cancelled = true
|
||||
}
|
||||
}, [pairingOffer?.url])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -597,7 +580,7 @@ function PairingOfferDialogContent(input: {
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
@@ -605,7 +588,7 @@ function PairingOfferDialogContent(input: {
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
@@ -613,7 +596,7 @@ function PairingOfferDialogContent(input: {
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -640,20 +623,20 @@ function PairingOfferDialogContent(input: {
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionLinkText: {
|
||||
@@ -661,9 +644,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
@@ -676,13 +659,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
statusValueGroup: {
|
||||
alignItems: "flex-end",
|
||||
alignItems: 'flex-end',
|
||||
gap: 2,
|
||||
},
|
||||
rowTitle: {
|
||||
@@ -713,7 +696,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.palette.amber[500],
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
@@ -726,15 +709,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
pairingState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
qrCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
minHeight: 220,
|
||||
minWidth: 220,
|
||||
padding: theme.spacing[4],
|
||||
@@ -759,13 +742,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
logOutput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
lineHeight: 18,
|
||||
},
|
||||
codeBlock: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
lineHeight: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -774,8 +757,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCliShimResult } from "./managed-runtime";
|
||||
import { parseCliSymlinkInstructions } from "./managed-runtime";
|
||||
|
||||
describe("parseCliShimResult", () => {
|
||||
it("parses manual install payloads from the desktop backend", () => {
|
||||
describe("parseCliSymlinkInstructions", () => {
|
||||
it("parses CLI symlink instructions from the desktop backend", () => {
|
||||
expect(
|
||||
parseCliShimResult({
|
||||
status: "manualInstallRequired",
|
||||
installed: false,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Install it manually.",
|
||||
manualInstructions: {
|
||||
title: "Install from Terminal",
|
||||
detail: "Run these commands.",
|
||||
commands: "sudo tee /usr/local/bin/paseo",
|
||||
},
|
||||
parseCliSymlinkInstructions({
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the Paseo desktop executable.",
|
||||
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
|
||||
})
|
||||
).toEqual({
|
||||
status: "manualInstallRequired",
|
||||
installed: false,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Install it manually.",
|
||||
manualInstructions: {
|
||||
title: "Install from Terminal",
|
||||
detail: "Run these commands.",
|
||||
commands: "sudo tee /usr/local/bin/paseo",
|
||||
},
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the Paseo desktop executable.",
|
||||
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to installed or removed when older payloads omit status", () => {
|
||||
expect(
|
||||
parseCliShimResult({
|
||||
installed: true,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Installed.",
|
||||
})
|
||||
).toEqual({
|
||||
status: "installed",
|
||||
installed: true,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Installed.",
|
||||
manualInstructions: null,
|
||||
});
|
||||
it("rejects non-object payloads", () => {
|
||||
expect(() => parseCliSymlinkInstructions(null)).toThrow(
|
||||
"Unexpected CLI symlink instructions response."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,282 +1,232 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
||||
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
|
||||
|
||||
export type ManagedRuntimeStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
diagnosticsRoot: string;
|
||||
stateFilePath: string;
|
||||
};
|
||||
runtimeId: string
|
||||
runtimeVersion: string
|
||||
runtimeRoot: string
|
||||
}
|
||||
|
||||
export type ManagedDaemonStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
daemonPid: number | null;
|
||||
daemonRunning: boolean;
|
||||
daemonStatus: string;
|
||||
logPath: string;
|
||||
serverId: string | null;
|
||||
hostname: string | null;
|
||||
relayEnabled: boolean;
|
||||
tcpEnabled: boolean;
|
||||
tcpListen: string | null;
|
||||
cliShimPath: string | null;
|
||||
};
|
||||
runtimeId: string
|
||||
runtimeVersion: string
|
||||
serverId: string
|
||||
status: string
|
||||
listen: string
|
||||
hostname: string | null
|
||||
pid: number | null
|
||||
home: string
|
||||
}
|
||||
|
||||
export type ManagedDaemonLogs = {
|
||||
logPath: string;
|
||||
contents: string;
|
||||
};
|
||||
logPath: string
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type ManagedPairingOffer = {
|
||||
relayEnabled: boolean;
|
||||
url: string | null;
|
||||
qr: string | null;
|
||||
};
|
||||
relayEnabled: boolean
|
||||
url: string | null
|
||||
qr: string | null
|
||||
}
|
||||
|
||||
export type CliShimResult = {
|
||||
status:
|
||||
| "installed"
|
||||
| "removed"
|
||||
| "elevationDenied"
|
||||
| "automaticInstallUnavailable"
|
||||
| "manualInstallRequired";
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
message: string;
|
||||
manualInstructions: CliManualInstructions | null;
|
||||
};
|
||||
|
||||
export type CliManualInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
export type CliSymlinkInstructions = {
|
||||
title: string
|
||||
detail: string
|
||||
commands: string
|
||||
}
|
||||
|
||||
export type ManagedTcpSettings = {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
enabled: boolean
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
};
|
||||
transportType: 'socket' | 'pipe'
|
||||
transportPath: string
|
||||
}
|
||||
|
||||
type LocalTransportEventPayload = {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
sessionId: string
|
||||
kind: 'open' | 'message' | 'close' | 'error'
|
||||
text?: string | null
|
||||
binaryBase64?: string | null
|
||||
code?: number | null
|
||||
reason?: string | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed runtime status response.");
|
||||
throw new Error('Unexpected managed runtime status response.')
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
|
||||
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
|
||||
};
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon status response.");
|
||||
throw new Error('Unexpected managed daemon status response.')
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
daemonPid: toNumberOrNull(raw.daemonPid),
|
||||
daemonRunning: raw.daemonRunning === true,
|
||||
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
serverId: toStringOrNull(raw.serverId),
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
||||
serverId: toStringOrNull(raw.serverId) ?? '',
|
||||
status: toStringOrNull(raw.status) ?? 'unknown',
|
||||
listen: toStringOrNull(raw.listen) ?? '',
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
tcpEnabled: raw.tcpEnabled === true,
|
||||
tcpListen: toStringOrNull(raw.tcpListen),
|
||||
cliShimPath: toStringOrNull(raw.cliShimPath),
|
||||
};
|
||||
pid: toNumberOrNull(raw.pid),
|
||||
home: toStringOrNull(raw.home) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon logs response.");
|
||||
throw new Error('Unexpected managed daemon logs response.')
|
||||
}
|
||||
return {
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
contents: typeof raw.contents === "string" ? raw.contents : "",
|
||||
};
|
||||
logPath: toStringOrNull(raw.logPath) ?? '',
|
||||
contents: typeof raw.contents === 'string' ? raw.contents : '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon pairing response.");
|
||||
throw new Error('Unexpected managed daemon pairing response.')
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
url: toStringOrNull(raw.url),
|
||||
qr: toStringOrNull(raw.qr),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
|
||||
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return {
|
||||
title: toStringOrNull(raw.title) ?? "",
|
||||
detail: toStringOrNull(raw.detail) ?? "",
|
||||
commands: toStringOrNull(raw.commands) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCliShimResult(raw: unknown): CliShimResult {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected CLI shim response.");
|
||||
title: toStringOrNull(raw.title) ?? '',
|
||||
detail: toStringOrNull(raw.detail) ?? '',
|
||||
commands: toStringOrNull(raw.commands) ?? '',
|
||||
}
|
||||
return {
|
||||
status:
|
||||
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
|
||||
(raw.installed === true ? "installed" : "removed"),
|
||||
installed: raw.installed === true,
|
||||
path: toStringOrNull(raw.path),
|
||||
message: toStringOrNull(raw.message) ?? "",
|
||||
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseManagedDesktopDaemon(): boolean {
|
||||
return isTauriEnvironment() && getTauri() !== null;
|
||||
return isTauriEnvironment() && getTauri() !== null
|
||||
}
|
||||
|
||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
|
||||
}
|
||||
|
||||
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
|
||||
}
|
||||
|
||||
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
|
||||
}
|
||||
|
||||
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
|
||||
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
|
||||
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
|
||||
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
|
||||
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
|
||||
}
|
||||
|
||||
export async function installManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
|
||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||
const instructions = parseCliSymlinkInstructionsInternal(raw)
|
||||
if (!instructions) {
|
||||
throw new Error('Unexpected CLI symlink instructions response.')
|
||||
}
|
||||
return instructions
|
||||
}
|
||||
|
||||
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
|
||||
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
|
||||
}
|
||||
|
||||
export async function updateManagedDaemonTcpSettings(
|
||||
settings: ManagedTcpSettings
|
||||
): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(
|
||||
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
|
||||
);
|
||||
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
|
||||
)
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void;
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
|
||||
export type LocalTransportEventUnlisten = () => void
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
|
||||
|
||||
export async function listenToLocalTransportEvents(
|
||||
handler: LocalTransportEventHandler
|
||||
): Promise<LocalTransportEventUnlisten> {
|
||||
const listen = getTauri()?.event?.listen;
|
||||
if (typeof listen !== "function") {
|
||||
throw new Error("Tauri event API is unavailable.");
|
||||
const listen = getTauri()?.event?.listen
|
||||
if (typeof listen !== 'function') {
|
||||
throw new Error('Tauri event API is unavailable.')
|
||||
}
|
||||
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
|
||||
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
|
||||
if (!payload) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
handler({
|
||||
sessionId: toStringOrNull(payload.sessionId) ?? "",
|
||||
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
|
||||
sessionId: toStringOrNull(payload.sessionId) ?? '',
|
||||
kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'],
|
||||
text: toStringOrNull(payload.text),
|
||||
binaryBase64: toStringOrNull(payload.binaryBase64),
|
||||
code: toNumberOrNull(payload.code),
|
||||
reason: toStringOrNull(payload.reason),
|
||||
error: toStringOrNull(payload.error),
|
||||
});
|
||||
});
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
})
|
||||
})
|
||||
return typeof unlisten === 'function' ? unlisten : () => {}
|
||||
}
|
||||
|
||||
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected local transport session response.");
|
||||
const raw = await invokeDesktopCommand<unknown>('open_local_daemon_transport', target)
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
throw new Error('Unexpected local transport session response.')
|
||||
}
|
||||
return raw;
|
||||
return raw
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
binaryBase64?: string;
|
||||
sessionId: string
|
||||
text?: string
|
||||
binaryBase64?: string
|
||||
}): Promise<void> {
|
||||
await invokeDesktopCommand("send_local_daemon_transport_message", {
|
||||
await invokeDesktopCommand('send_local_daemon_transport_message', {
|
||||
sessionId: input.sessionId,
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
||||
await invokeDesktopCommand('close_local_daemon_transport', { sessionId })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
|
||||
|
||||
export interface DesktopNotificationInput {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationClickPayload {
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type DesktopNotificationClickHandler = (
|
||||
payload: DesktopNotificationClickPayload
|
||||
) => void;
|
||||
|
||||
export type DesktopNotificationClickUnlisten = () => void;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function sendDesktopNotification(
|
||||
input: DesktopNotificationInput
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await invokeDesktopCommand("send_desktop_notification", { input });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listenToDesktopNotificationClicks(
|
||||
handler: DesktopNotificationClickHandler
|
||||
): Promise<DesktopNotificationClickUnlisten> {
|
||||
const listen = getTauri()?.event?.listen;
|
||||
if (typeof listen !== "function") {
|
||||
throw new Error("Tauri event API is unavailable.");
|
||||
}
|
||||
|
||||
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler({
|
||||
data: isRecord(payload.data) ? payload.data : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
107
packages/app/src/hooks/image-attachment-picker.test.ts
Normal file
107
packages/app/src/hooks/image-attachment-picker.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const tauriState = vi.hoisted(() => ({
|
||||
api: null as any,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/tauri", () => ({
|
||||
getTauri: () => tauriState.api,
|
||||
}));
|
||||
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
} from "./image-attachment-picker";
|
||||
|
||||
describe("image-attachment-picker", () => {
|
||||
beforeEach(() => {
|
||||
tauriState.api = null;
|
||||
});
|
||||
|
||||
it("normalizes a picked File into a blob source", async () => {
|
||||
const file = new File(["hello"], "picked.png", { type: "image/png" });
|
||||
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "blob:test",
|
||||
mimeType: "image/png",
|
||||
fileName: null,
|
||||
file,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source.kind).toBe("blob");
|
||||
expect(result[0]?.fileName).toBe("picked.png");
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("keeps filesystem picker results as file uris", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///tmp/picked.png",
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///tmp/picked.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts data urls into blob sources when no file path exists", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "data:image/png;base64,AAEC",
|
||||
mimeType: "image/png",
|
||||
fileName: "inline.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source.kind).toBe("blob");
|
||||
expect(result[0]?.fileName).toBe("inline.png");
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uses the tauri dialog api when available", async () => {
|
||||
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
tauriState.api = {
|
||||
dialog: { open },
|
||||
};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
});
|
||||
|
||||
it("falls back to core invoke for the tauri dialog plugin", async () => {
|
||||
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
|
||||
tauriState.api = {
|
||||
core: { invoke },
|
||||
};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
|
||||
options: expect.objectContaining({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
}),
|
||||
});
|
||||
expect(result).toEqual(["/tmp/one.png"]);
|
||||
});
|
||||
});
|
||||
110
packages/app/src/hooks/image-attachment-picker.ts
Normal file
110
packages/app/src/hooks/image-attachment-picker.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export type PickedImageSource =
|
||||
| { kind: "file_uri"; uri: string }
|
||||
| { kind: "blob"; blob: Blob };
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpoImagePickerAssetLike {
|
||||
uri: string;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"avif",
|
||||
"heic",
|
||||
"heif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"svg",
|
||||
];
|
||||
|
||||
function isAbsoluteWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function shouldTreatAsFileUri(uri: string): boolean {
|
||||
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
|
||||
}
|
||||
|
||||
async function blobFromUri(uri: string): Promise<Blob> {
|
||||
const response = await fetch(uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read picked image from '${uri}'.`);
|
||||
}
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[]
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.file instanceof Blob) {
|
||||
return {
|
||||
source: { kind: "blob", blob: asset.file },
|
||||
mimeType: asset.mimeType ?? asset.file.type ?? null,
|
||||
fileName: asset.fileName ?? asset.file.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldTreatAsFileUri(asset.uri)) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
|
||||
if (!selection) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(selection) ? selection : [selection];
|
||||
}
|
||||
|
||||
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
||||
const tauri = getTauri();
|
||||
const options = {
|
||||
directory: false,
|
||||
multiple: true,
|
||||
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
|
||||
title: "Attach images",
|
||||
};
|
||||
|
||||
const dialogOpen = tauri?.dialog?.open;
|
||||
if (typeof dialogOpen === "function") {
|
||||
return normalizeTauriDialogSelection(await dialogOpen(options));
|
||||
}
|
||||
|
||||
const invoke = tauri?.core?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Tauri dialog API is not available.");
|
||||
}
|
||||
|
||||
const result = await invoke("plugin:dialog|open", { options });
|
||||
return normalizeTauriDialogSelection(
|
||||
Array.isArray(result) || typeof result === "string" || result === null ? result : null
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
shouldClearAgentAttention,
|
||||
type AgentAttentionClearTrigger,
|
||||
} from "@/utils/agent-attention";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
|
||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
@@ -23,20 +24,6 @@ interface AgentAttentionClearController {
|
||||
clearOnAgentBlur: () => void;
|
||||
}
|
||||
|
||||
function getIsAppVisible(): boolean {
|
||||
const isAppStateActive = AppState.currentState === "active";
|
||||
if (Platform.OS !== "web") {
|
||||
return isAppStateActive;
|
||||
}
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const windowFocused =
|
||||
typeof document === "undefined" ||
|
||||
typeof document.hasFocus !== "function" ||
|
||||
document.hasFocus();
|
||||
return isAppStateActive && documentVisible && windowFocused;
|
||||
}
|
||||
|
||||
export function useAgentAttentionClear({
|
||||
agentId,
|
||||
client,
|
||||
@@ -45,12 +32,14 @@ export function useAgentAttentionClear({
|
||||
attentionReason,
|
||||
isScreenFocused,
|
||||
}: UseAgentAttentionClearParams): AgentAttentionClearController {
|
||||
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppVisible());
|
||||
const [isAppVisible, setIsAppVisible] = useState<boolean>(() =>
|
||||
getIsAppActivelyVisible()
|
||||
);
|
||||
const deferredFocusEntryClearRef = useRef(false);
|
||||
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
|
||||
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppVisible());
|
||||
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
|
||||
const prevScreenFocusedRef = useRef(false);
|
||||
const prevAppVisibleRef = useRef(getIsAppVisible());
|
||||
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
|
||||
|
||||
const clearAttention = useCallback(
|
||||
(trigger: AgentAttentionClearTrigger) => {
|
||||
@@ -78,7 +67,7 @@ export function useAgentAttentionClear({
|
||||
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
setIsAppVisible(getIsAppVisible());
|
||||
setIsAppVisible(getIsAppActivelyVisible());
|
||||
};
|
||||
|
||||
const appStateSubscription = AppState.addEventListener(
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||
|
||||
@@ -327,7 +327,7 @@ export function useAgentFormState(
|
||||
updateProviderPreferences,
|
||||
} = useFormPreferences();
|
||||
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||
serverId: string;
|
||||
@@ -22,7 +21,7 @@ export interface AggregatedAgentsResult {
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -74,7 +74,7 @@ export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
includeArchived?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
|
||||
1
packages/app/src/hooks/use-audio-player.d.ts
vendored
1
packages/app/src/hooks/use-audio-player.d.ts
vendored
@@ -1 +0,0 @@
|
||||
export * from "./use-audio-player.native";
|
||||
@@ -1,366 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
initialize,
|
||||
playPCMData,
|
||||
stopPlayback,
|
||||
pausePlayback,
|
||||
resumePlayback,
|
||||
} from "@boudra/expo-two-way-audio";
|
||||
|
||||
interface QueuedAudio {
|
||||
audioData: Blob;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample PCM16 audio between sample rates.
|
||||
* Speechmatics expects 16kHz.
|
||||
*/
|
||||
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const out = new Uint8Array(outputSamples * 2);
|
||||
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const i = sampleIndex * 2;
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i]!;
|
||||
const hi = pcm[i + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const i = sampleIndex * 2;
|
||||
out[i] = clamped & 0xff;
|
||||
out[i + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcPos = i * ratio;
|
||||
const i0 = Math.floor(srcPos);
|
||||
const frac = srcPos - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
export interface AudioPlayerOptions {
|
||||
isDetecting?: () => boolean;
|
||||
isSpeaking?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for audio playback using Speechmatics two-way audio with echo cancellation
|
||||
*/
|
||||
export function useAudioPlayer(options?: AudioPlayerOptions) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||
const queueRef = useRef<QueuedAudio[]>([]);
|
||||
const suppressedQueueRef = useRef<QueuedAudio[]>([]);
|
||||
const isProcessingQueueRef = useRef(false);
|
||||
const activePlaybackRef = useRef<{
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
} | null>(null);
|
||||
const playbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
async function play(audioData: Blob): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if we should suppress playback due to voice detection/speaking
|
||||
const shouldSuppress =
|
||||
(options?.isDetecting && options.isDetecting()) ||
|
||||
(options?.isSpeaking && options.isSpeaking());
|
||||
|
||||
if (shouldSuppress) {
|
||||
console.log("[AudioPlayer] Suppressing playback - voice detection/speaking active");
|
||||
// Add to suppressed queue instead
|
||||
suppressedQueueRef.current.push({ audioData, resolve, reject });
|
||||
|
||||
// Start checking for when flags clear
|
||||
startCheckingForClearFlags();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue with its promise handlers
|
||||
queueRef.current.push({ audioData, resolve, reject });
|
||||
|
||||
// Start processing queue if not already processing
|
||||
if (!isProcessingQueueRef.current) {
|
||||
processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startCheckingForClearFlags(): void {
|
||||
// Already checking
|
||||
if (checkIntervalRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[AudioPlayer] Starting to check for clear flags");
|
||||
|
||||
checkIntervalRef.current = setInterval(() => {
|
||||
const isStillBlocked =
|
||||
(options?.isDetecting && options.isDetecting()) ||
|
||||
(options?.isSpeaking && options.isSpeaking());
|
||||
|
||||
if (!isStillBlocked && suppressedQueueRef.current.length > 0) {
|
||||
console.log("[AudioPlayer] Flags cleared - moving suppressed queue to main queue");
|
||||
|
||||
// Move all suppressed items to main queue
|
||||
const suppressedItems = [...suppressedQueueRef.current];
|
||||
suppressedQueueRef.current = [];
|
||||
|
||||
// Add to front of main queue (they were waiting)
|
||||
queueRef.current = [...suppressedItems, ...queueRef.current];
|
||||
|
||||
// Stop checking
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Start processing if not already
|
||||
if (!isProcessingQueueRef.current) {
|
||||
processQueue();
|
||||
}
|
||||
} else if (!isStillBlocked && suppressedQueueRef.current.length === 0) {
|
||||
// No more suppressed items and flags are clear - stop checking
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 100); // Check every 100ms
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = true;
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
// Before processing each item, check if flags became active
|
||||
const shouldSuppress =
|
||||
(options?.isDetecting && options.isDetecting()) ||
|
||||
(options?.isSpeaking && options.isSpeaking());
|
||||
|
||||
if (shouldSuppress) {
|
||||
console.log("[AudioPlayer] Flags became active during processing - moving remaining queue to suppressed");
|
||||
// Move remaining queue to suppressed
|
||||
suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current];
|
||||
queueRef.current = [];
|
||||
startCheckingForClearFlags();
|
||||
break;
|
||||
}
|
||||
|
||||
const item = queueRef.current.shift()!;
|
||||
try {
|
||||
const duration = await playAudio(item.audioData);
|
||||
item.resolve(duration);
|
||||
} catch (error) {
|
||||
item.reject(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = false;
|
||||
}
|
||||
|
||||
async function processNextInQueue(): Promise<void> {
|
||||
if (queueRef.current.length > 0) {
|
||||
const item = queueRef.current.shift()!;
|
||||
try {
|
||||
const duration = await playAudio(item.audioData);
|
||||
item.resolve(duration);
|
||||
} catch (error) {
|
||||
item.reject(error as Error);
|
||||
}
|
||||
} else {
|
||||
isProcessingQueueRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAudio(audioData: Blob): Promise<number> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
activePlaybackRef.current = { resolve, reject };
|
||||
try {
|
||||
console.log(
|
||||
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
|
||||
);
|
||||
|
||||
// Initialize audio if not already initialized
|
||||
if (!audioInitialized) {
|
||||
console.log("[AudioPlayer] Initializing audio...");
|
||||
await initialize();
|
||||
setAudioInitialized(true);
|
||||
console.log(
|
||||
"[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)"
|
||||
);
|
||||
}
|
||||
|
||||
// Workaround: Resume playback before playing new audio to ensure the audio engine is ready
|
||||
// This fixes the issue where playback doesn't work after calling stopPlayback()
|
||||
console.log("[AudioPlayer] Resuming playback engine...");
|
||||
resumePlayback();
|
||||
|
||||
// Get PCM data from blob (server sends PCM16)
|
||||
const arrayBuffer = await audioData.arrayBuffer();
|
||||
const pcm = new Uint8Array(arrayBuffer);
|
||||
|
||||
const inputRate = parsePcmSampleRate(audioData.type || "") ?? 24000;
|
||||
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
|
||||
|
||||
// Calculate total duration
|
||||
const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample
|
||||
const durationSec = samples / 16000; // 16kHz sample rate
|
||||
|
||||
const audioSizeKb = (pcm16k.length / 1024).toFixed(2);
|
||||
console.log(
|
||||
"[AudioPlayer] 🔊 Playing audio:",
|
||||
audioSizeKb,
|
||||
"KB, duration:",
|
||||
durationSec.toFixed(2),
|
||||
"s"
|
||||
);
|
||||
|
||||
setIsPlaying(true);
|
||||
|
||||
// Play entire PCM data at once through Speechmatics
|
||||
playPCMData(pcm16k);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (playbackTimeoutRef.current) {
|
||||
clearTimeout(playbackTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Wait for playback to finish (estimate based on duration)
|
||||
playbackTimeoutRef.current = setTimeout(() => {
|
||||
console.log("[AudioPlayer] ✅ Playback finished");
|
||||
setIsPlaying(false);
|
||||
playbackTimeoutRef.current = null;
|
||||
activePlaybackRef.current = null;
|
||||
resolve(durationSec);
|
||||
}, durationSec * 1000);
|
||||
} catch (error) {
|
||||
console.error("[AudioPlayer] Error playing audio:", error);
|
||||
|
||||
// Clear timeout on error
|
||||
if (playbackTimeoutRef.current) {
|
||||
clearTimeout(playbackTimeoutRef.current);
|
||||
playbackTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsPlaying(false);
|
||||
activePlaybackRef.current = null;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (isPlaying) {
|
||||
console.log("[AudioPlayer] 🛑 Stopping playback (interrupted)");
|
||||
|
||||
// Stop native playback
|
||||
stopPlayback();
|
||||
|
||||
// Clear playback timeout
|
||||
if (playbackTimeoutRef.current) {
|
||||
clearTimeout(playbackTimeoutRef.current);
|
||||
playbackTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
setIsPlaying(false);
|
||||
|
||||
// Reject the currently playing promise, if any.
|
||||
if (activePlaybackRef.current) {
|
||||
activePlaybackRef.current.reject(new Error("Playback stopped"));
|
||||
activePlaybackRef.current = null;
|
||||
}
|
||||
|
||||
// Reject all pending promises in the main queue
|
||||
while (queueRef.current.length > 0) {
|
||||
const item = queueRef.current.shift()!;
|
||||
item.reject(new Error("Playback stopped"));
|
||||
}
|
||||
|
||||
// Reject all pending promises in the suppressed queue
|
||||
while (suppressedQueueRef.current.length > 0) {
|
||||
const item = suppressedQueueRef.current.shift()!;
|
||||
item.reject(new Error("Playback stopped"));
|
||||
}
|
||||
|
||||
// Clear check interval
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = false;
|
||||
}
|
||||
|
||||
function clearQueue(): void {
|
||||
// Reject all pending promises in the main queue
|
||||
while (queueRef.current.length > 0) {
|
||||
const item = queueRef.current.shift()!;
|
||||
item.reject(new Error("Queue cleared"));
|
||||
}
|
||||
|
||||
// Reject all pending promises in the suppressed queue
|
||||
while (suppressedQueueRef.current.length > 0) {
|
||||
const item = suppressedQueueRef.current.shift()!;
|
||||
item.reject(new Error("Queue cleared"));
|
||||
}
|
||||
|
||||
// Clear check interval
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
play,
|
||||
stop,
|
||||
isPlaying: () => isPlaying,
|
||||
clearQueue,
|
||||
warmup: async () => {
|
||||
if (!audioInitialized) {
|
||||
await initialize();
|
||||
setAudioInitialized(true);
|
||||
}
|
||||
// Ensure playback engine isn't suspended after a previous stop.
|
||||
resumePlayback();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
export interface AudioPlayerOptions {
|
||||
isDetecting?: () => boolean;
|
||||
isSpeaking?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web audio player for server-provided audio chunks.
|
||||
*
|
||||
* Supports:
|
||||
* - `audio/pcm` (assumed PCM16 LE, mono, default 24kHz unless `rate=` is present)
|
||||
* - formats supported by `AudioContext.decodeAudioData` (e.g. mp3)
|
||||
*/
|
||||
export function useAudioPlayer(options?: AudioPlayerOptions) {
|
||||
const [isPlayingState, setIsPlayingState] = useState(false);
|
||||
|
||||
type QueuedAudio = {
|
||||
audioData: Blob;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const queueRef = useRef<QueuedAudio[]>([]);
|
||||
const suppressedQueueRef = useRef<QueuedAudio[]>([]);
|
||||
const isProcessingQueueRef = useRef(false);
|
||||
const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const activePlaybackRef = useRef<{
|
||||
source: AudioBufferSourceNode;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const decodeAudioData = async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
||||
const maybePromise = context.decodeAudioData(buffer);
|
||||
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
|
||||
return maybePromise as Promise<AudioBuffer>;
|
||||
}
|
||||
return await new Promise<AudioBuffer>((resolve, reject) => {
|
||||
context.decodeAudioData(buffer, resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
const parsePcmSampleRate = (mimeType: string): number | null => {
|
||||
const match = /rate=(\\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
};
|
||||
|
||||
const pcm16LeToAudioBuffer = (context: AudioContext, bytes: Uint8Array, sampleRate: number): AudioBuffer => {
|
||||
const sampleCount = Math.floor(bytes.length / 2);
|
||||
const audioBuffer = context.createBuffer(1, sampleCount, sampleRate);
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
const lo = bytes[i * 2]!;
|
||||
const hi = bytes[i * 2 + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
channel[i] = value / 0x8000;
|
||||
}
|
||||
return audioBuffer;
|
||||
};
|
||||
|
||||
const ensureContext = async (): Promise<AudioContext> => {
|
||||
if (audioContextRef.current) {
|
||||
if (audioContextRef.current.state === "suspended") {
|
||||
try {
|
||||
await audioContextRef.current.resume();
|
||||
} catch {
|
||||
// Best effort. If this fails due to autoplay policies, a later user gesture
|
||||
// (or explicit warmup call) can unlock it.
|
||||
}
|
||||
}
|
||||
return audioContextRef.current;
|
||||
}
|
||||
const context = new AudioContext();
|
||||
if (context.state === "suspended") {
|
||||
try {
|
||||
await context.resume();
|
||||
} catch {
|
||||
// See note above.
|
||||
}
|
||||
}
|
||||
audioContextRef.current = context;
|
||||
return context;
|
||||
};
|
||||
|
||||
const shouldSuppressPlayback = (): boolean => {
|
||||
return Boolean(options?.isDetecting?.()) || Boolean(options?.isSpeaking?.());
|
||||
};
|
||||
|
||||
const startCheckingForClearFlags = (): void => {
|
||||
if (checkIntervalRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkIntervalRef.current = setInterval(() => {
|
||||
const isStillBlocked = shouldSuppressPlayback();
|
||||
if (!isStillBlocked && suppressedQueueRef.current.length > 0) {
|
||||
const suppressedItems = [...suppressedQueueRef.current];
|
||||
suppressedQueueRef.current = [];
|
||||
queueRef.current = [...suppressedItems, ...queueRef.current];
|
||||
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!isProcessingQueueRef.current) {
|
||||
void processQueue();
|
||||
}
|
||||
} else if (!isStillBlocked && suppressedQueueRef.current.length === 0) {
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const playAudio = async (audioData: Blob): Promise<number> => {
|
||||
const context = await ensureContext();
|
||||
const arrayBuffer = await audioData.arrayBuffer();
|
||||
|
||||
let audioBuffer: AudioBuffer;
|
||||
const type = (audioData.type || "").toLowerCase();
|
||||
|
||||
if (type.startsWith("audio/pcm")) {
|
||||
const sampleRate = parsePcmSampleRate(type) ?? 24000;
|
||||
audioBuffer = pcm16LeToAudioBuffer(context, new Uint8Array(arrayBuffer), sampleRate);
|
||||
} else {
|
||||
audioBuffer = await decodeAudioData(context, arrayBuffer);
|
||||
}
|
||||
|
||||
const durationSec = audioBuffer.duration;
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(context.destination);
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
activePlaybackRef.current = { source, resolve, reject, settled: false };
|
||||
setIsPlayingState(true);
|
||||
|
||||
const settleOnce = (fn: () => void) => {
|
||||
const active = activePlaybackRef.current;
|
||||
if (!active || active.source !== source || active.settled) {
|
||||
return;
|
||||
}
|
||||
active.settled = true;
|
||||
activePlaybackRef.current = null;
|
||||
setIsPlayingState(false);
|
||||
fn();
|
||||
};
|
||||
|
||||
source.onended = () => {
|
||||
settleOnce(() => resolve(durationSec));
|
||||
};
|
||||
|
||||
try {
|
||||
source.start();
|
||||
} catch (e) {
|
||||
settleOnce(() => reject(e instanceof Error ? e : new Error(String(e))));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const processQueue = async (): Promise<void> => {
|
||||
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = true;
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
if (shouldSuppressPlayback()) {
|
||||
suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current];
|
||||
queueRef.current = [];
|
||||
startCheckingForClearFlags();
|
||||
break;
|
||||
}
|
||||
|
||||
const item = queueRef.current.shift()!;
|
||||
try {
|
||||
const duration = await playAudio(item.audioData);
|
||||
item.resolve(duration);
|
||||
} catch (error) {
|
||||
item.reject(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = false;
|
||||
};
|
||||
|
||||
const play = async (audioData: Blob): Promise<number> => {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
if (shouldSuppressPlayback()) {
|
||||
suppressedQueueRef.current.push({ audioData, resolve, reject });
|
||||
startCheckingForClearFlags();
|
||||
return;
|
||||
}
|
||||
|
||||
queueRef.current.push({ audioData, resolve, reject });
|
||||
if (!isProcessingQueueRef.current) {
|
||||
void processQueue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
// Stop currently playing audio (and reject its promise)
|
||||
if (activePlaybackRef.current) {
|
||||
const active = activePlaybackRef.current;
|
||||
activePlaybackRef.current = null;
|
||||
try {
|
||||
active.source.onended = null;
|
||||
active.source.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!active.settled) {
|
||||
active.settled = true;
|
||||
active.reject(new Error("Playback stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
setIsPlayingState(false);
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
queueRef.current.shift()!.reject(new Error("Playback stopped"));
|
||||
}
|
||||
while (suppressedQueueRef.current.length > 0) {
|
||||
suppressedQueueRef.current.shift()!.reject(new Error("Playback stopped"));
|
||||
}
|
||||
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = false;
|
||||
};
|
||||
|
||||
const clearQueue = (): void => {
|
||||
while (queueRef.current.length > 0) {
|
||||
queueRef.current.shift()!.reject(new Error("Queue cleared"));
|
||||
}
|
||||
while (suppressedQueueRef.current.length > 0) {
|
||||
suppressedQueueRef.current.shift()!.reject(new Error("Queue cleared"));
|
||||
}
|
||||
if (checkIntervalRef.current !== null) {
|
||||
clearInterval(checkIntervalRef.current);
|
||||
checkIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
play,
|
||||
stop,
|
||||
isPlaying: () => isPlayingState,
|
||||
clearQueue,
|
||||
warmup: async () => {
|
||||
await ensureContext();
|
||||
},
|
||||
}),
|
||||
[isPlayingState]
|
||||
);
|
||||
}
|
||||
@@ -18,41 +18,27 @@ export interface AudioCaptureConfig {
|
||||
*/
|
||||
async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
||||
try {
|
||||
console.log('[AudioRecorder] Searching for recording file created at:', createdAt.toISOString());
|
||||
|
||||
const audioDir = new Directory(Paths.cache, 'Audio');
|
||||
console.log('[AudioRecorder] Audio cache directory URI:', audioDir.uri);
|
||||
console.log('[AudioRecorder] Directory exists:', audioDir.exists);
|
||||
|
||||
if (!audioDir.exists) {
|
||||
console.log('[AudioRecorder] Audio cache directory does not exist');
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = audioDir.list();
|
||||
console.log('[AudioRecorder] Found files in Audio cache:', files.length);
|
||||
|
||||
if (!files.length) {
|
||||
console.log('[AudioRecorder] No files found in Audio cache directory');
|
||||
return null;
|
||||
}
|
||||
|
||||
const validFiles = files
|
||||
.map(file => {
|
||||
const info = file.info();
|
||||
console.log('[AudioRecorder] File info:', {
|
||||
uri: info.uri,
|
||||
size: info.size,
|
||||
creationTime: info.creationTime ? new Date(info.creationTime).toISOString() : null,
|
||||
});
|
||||
return info;
|
||||
})
|
||||
.filter(f => f.size && f.size > 0);
|
||||
|
||||
console.log('[AudioRecorder] Valid files (size > 0):', validFiles.length);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
console.log('[AudioRecorder] No valid files found (all are zero-byte)');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,10 +48,6 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
||||
for (const file of validFiles) {
|
||||
if (!file.creationTime || !file.uri) continue;
|
||||
const diff = Math.abs(file.creationTime - createdAt.getTime());
|
||||
console.log('[AudioRecorder] Time diff for file:', {
|
||||
uri: file.uri,
|
||||
diffMs: diff,
|
||||
});
|
||||
if (diff < minDiff) {
|
||||
closest = file;
|
||||
minDiff = diff;
|
||||
@@ -74,15 +56,9 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
||||
|
||||
if (closest) {
|
||||
const resultUri = closest.uri?.slice(0, -1) ?? null;
|
||||
console.log('[AudioRecorder] Found closest file:', {
|
||||
uri: resultUri,
|
||||
size: closest.size,
|
||||
timeDiffMs: minDiff,
|
||||
});
|
||||
return resultUri;
|
||||
}
|
||||
|
||||
console.log('[AudioRecorder] No closest file found');
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('[AudioRecorder] Error finding actual recording file:', e);
|
||||
@@ -208,7 +184,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
// Request microphone permissions
|
||||
console.log('[AudioRecorder] Requesting recording permissions...');
|
||||
const permissionResponse = await requestRecordingPermissionsAsync();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
@@ -217,38 +192,26 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
}
|
||||
|
||||
// Configure audio mode for recording
|
||||
console.log('[AudioRecorder] Configuring audio mode...');
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
allowsRecording: true,
|
||||
});
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
console.log('[AudioRecorder] Starting recording with options:', {
|
||||
sampleRate: recordingOptions.sampleRate,
|
||||
numberOfChannels: recordingOptions.numberOfChannels,
|
||||
bitRate: recordingOptions.bitRate,
|
||||
});
|
||||
|
||||
const startTime = new Date();
|
||||
setRecordingStartTime(startTime);
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
// Prepare the recorder before recording (required step)
|
||||
console.log('[AudioRecorder] Preparing recorder...');
|
||||
await recorder.prepareToRecordAsync();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
console.log('[AudioRecorder] Starting recording...');
|
||||
await recorder.record();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
console.log('[AudioRecorder] Recording started at:', startTime.toISOString());
|
||||
console.log('[AudioRecorder] Recorder isRecording:', recorder.isRecording);
|
||||
} catch (error: any) {
|
||||
setRecordingStartTime(null);
|
||||
if (error instanceof AttemptCancelledError) {
|
||||
console.log('[AudioRecorder] Recording start cancelled.');
|
||||
return;
|
||||
}
|
||||
if (error?.message !== 'Recording cancelled') {
|
||||
@@ -278,15 +241,12 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
|
||||
// Get URI from recorder
|
||||
let uri = recorder.uri;
|
||||
console.log('[AudioRecorder] Initial URI from recorder:', uri);
|
||||
|
||||
// Workaround for Expo SDK 54 Android bug - find actual recording file
|
||||
if (recordingStartTime && (!uri || uri === '')) {
|
||||
console.log('[AudioRecorder] Using workaround to find actual recording file...');
|
||||
const actualUri = await getActualRecordingUri(recordingStartTime);
|
||||
if (actualUri) {
|
||||
uri = actualUri;
|
||||
console.log('[AudioRecorder] Found actual recording URI:', uri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +259,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
// Get file info
|
||||
const file = new File(uri);
|
||||
const exists = file.exists;
|
||||
console.log('[AudioRecorder] File exists:', exists);
|
||||
|
||||
if (!exists) {
|
||||
setRecordingStartTime(null);
|
||||
@@ -309,11 +268,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
// Convert URI to Blob
|
||||
const audioBlob = await uriToBlob(uri);
|
||||
|
||||
console.log('[AudioRecorder] Recording converted to blob:', {
|
||||
size: audioBlob.size,
|
||||
type: audioBlob.type,
|
||||
});
|
||||
|
||||
// Clean up the temporary file
|
||||
file.delete();
|
||||
|
||||
|
||||
@@ -163,16 +163,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
|
||||
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}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
@@ -104,7 +104,7 @@ export type CommandCenterItem =
|
||||
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
@@ -1,44 +1,63 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
|
||||
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
||||
const onPcmSegmentRef = useRef(config.onPcmSegment);
|
||||
const onErrorRef = useRef(config.onError);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
|
||||
|
||||
const getOrCreateEngine = useCallback(() => {
|
||||
if (engineRef.current) {
|
||||
return engineRef.current;
|
||||
}
|
||||
|
||||
engineRef.current = createAudioEngine({
|
||||
onCaptureData: (pcm) => {
|
||||
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
|
||||
},
|
||||
onVolumeLevel: (level) => {
|
||||
setVolume(level);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorRef.current?.(error);
|
||||
},
|
||||
});
|
||||
return engineRef.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onPcmSegmentRef.current = config.onPcmSegment;
|
||||
onErrorRef.current = config.onError;
|
||||
}, [config.onPcmSegment, config.onError]);
|
||||
|
||||
const speechmatics = useSpeechmaticsAudio({
|
||||
enableContinuousStreaming: true,
|
||||
onAudioSegment: ({ audioData }) => {
|
||||
onPcmSegmentRef.current(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
onErrorRef.current?.(err);
|
||||
},
|
||||
volumeThreshold: 0.3,
|
||||
silenceDuration: 2000,
|
||||
speechConfirmationDuration: 300,
|
||||
detectionGracePeriod: 200,
|
||||
});
|
||||
|
||||
const start = useCallback(async () => {
|
||||
await speechmatics.start();
|
||||
}, [speechmatics]);
|
||||
const engine = getOrCreateEngine();
|
||||
await engine.initialize();
|
||||
await engine.startCapture();
|
||||
}, [getOrCreateEngine]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
await speechmatics.stop();
|
||||
}, [speechmatics]);
|
||||
await engineRef.current?.stopCapture();
|
||||
setVolume(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const engine = engineRef.current;
|
||||
engineRef.current = null;
|
||||
void engine?.destroy().catch(() => undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
volume: speechmatics.volume,
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
@@ -65,84 +66,6 @@ const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||
return out;
|
||||
};
|
||||
|
||||
type Pcm16Wav = {
|
||||
sampleRate: number;
|
||||
samples: Int16Array;
|
||||
};
|
||||
|
||||
const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => {
|
||||
if (buffer.byteLength < 44) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const readAscii = (offset: number, length: number): string => {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
out += String.fromCharCode(view.getUint8(offset + i));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let channels = 0;
|
||||
let sampleRate = 0;
|
||||
let bitsPerSample = 0;
|
||||
let dataOffset = 0;
|
||||
let dataSize = 0;
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const chunkId = readAscii(offset, 4);
|
||||
const chunkSize = view.getUint32(offset + 4, true);
|
||||
const chunkDataOffset = offset + 8;
|
||||
if (chunkDataOffset + chunkSize > buffer.byteLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunkId === "fmt " && chunkSize >= 16) {
|
||||
const audioFormat = view.getUint16(chunkDataOffset, true);
|
||||
channels = view.getUint16(chunkDataOffset + 2, true);
|
||||
sampleRate = view.getUint32(chunkDataOffset + 4, true);
|
||||
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
|
||||
if (audioFormat !== 1) {
|
||||
return null;
|
||||
}
|
||||
} else if (chunkId === "data") {
|
||||
dataOffset = chunkDataOffset;
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
|
||||
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleCount = Math.floor(dataSize / 2);
|
||||
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
|
||||
|
||||
if (channels === 1) {
|
||||
return { sampleRate, samples: new Int16Array(interleaved) };
|
||||
}
|
||||
|
||||
const frameCount = Math.floor(interleaved.length / channels);
|
||||
const mono = new Int16Array(frameCount);
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
let sum = 0;
|
||||
for (let ch = 0; ch < channels; ch += 1) {
|
||||
sum += interleaved[frame * channels + ch] ?? 0;
|
||||
}
|
||||
mono[frame] = Math.round(sum / channels);
|
||||
}
|
||||
return { sampleRate, samples: mono };
|
||||
};
|
||||
|
||||
const int16ToBase64 = (pcm: Int16Array): string => {
|
||||
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
let binary = "";
|
||||
@@ -240,15 +163,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -13,18 +13,6 @@ interface UseExplorerOpenGestureParams {
|
||||
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,
|
||||
@@ -82,7 +70,6 @@ export function useExplorerOpenGesture({
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
runOnJS(logExplorerOpenGesture)("start", { enabled });
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
|
||||
@@ -103,15 +90,6 @@ export function useExplorerOpenGesture({
|
||||
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)();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
|
||||
type ImagePickerSuccessResult = ImagePicker.ImagePickerSuccessResult;
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
type PickedImageAttachmentInput,
|
||||
} from "@/hooks/image-attachment-picker";
|
||||
|
||||
interface UseImageAttachmentPickerResult {
|
||||
pickImages: () => Promise<ImagePickerSuccessResult | null>;
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
}
|
||||
|
||||
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
@@ -37,6 +42,18 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "web" && isTauriEnvironment()) {
|
||||
const selectedPaths = await openImagePathsWithTauriDialog();
|
||||
if (selectedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return selectedPaths.map((path) => ({
|
||||
source: { kind: "file_uri" as const, uri: path },
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const hasPermission = await ensurePermission();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
@@ -44,7 +61,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
|
||||
const pendingResult = await ImagePicker.getPendingResultAsync();
|
||||
if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) {
|
||||
return pendingResult as ImagePickerSuccessResult;
|
||||
return await normalizePickedImageAssets(pendingResult.assets);
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
@@ -57,7 +74,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
return await normalizePickedImageAssets(result.assets);
|
||||
} catch (error) {
|
||||
console.error("[ImageAttachmentPicker] Failed to pick image:", error);
|
||||
Alert.alert("Error", "Failed to select image");
|
||||
|
||||
71
packages/app/src/hooks/use-sidebar-shortcut-model.ts
Normal file
71
packages/app/src/hooks/use-sidebar-shortcut-model.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||
|
||||
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||
(state) => state.setSidebarShortcutWorkspaceTargets
|
||||
)
|
||||
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
|
||||
(state) => state.setVisibleWorkspaceTargets
|
||||
)
|
||||
|
||||
const shortcutModel = useMemo(
|
||||
() =>
|
||||
buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys,
|
||||
}),
|
||||
[collapsedProjectKeys, projects]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||
const next = new Set<string>()
|
||||
for (const key of prev) {
|
||||
if (validProjectKeys.has(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [projects])
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
|
||||
setVisibleWorkspaceTargets(shortcutModel.visibleTargets)
|
||||
}, [
|
||||
setSidebarShortcutWorkspaceTargets,
|
||||
setVisibleWorkspaceTargets,
|
||||
shortcutModel.shortcutTargets,
|
||||
shortcutModel.visibleTargets,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSidebarShortcutWorkspaceTargets([])
|
||||
setVisibleWorkspaceTargets([])
|
||||
}
|
||||
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets])
|
||||
|
||||
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectKey)) {
|
||||
next.delete(projectKey)
|
||||
} else {
|
||||
next.add(projectKey)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
}
|
||||
}
|
||||
@@ -267,8 +267,6 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const value = options?.serverId
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}, [options?.serverId])
|
||||
const enabled = options?.enabled ?? true
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
|
||||
)
|
||||
@@ -345,7 +343,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId])
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
|
||||
if (!isActive || !serverId || connectionStatus !== 'online') {
|
||||
return
|
||||
}
|
||||
const client = runtime.getClient(serverId)
|
||||
@@ -377,7 +375,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})()
|
||||
}, [connectionStatus, enabled, isActive, runtime, serverId])
|
||||
}, [connectionStatus, isActive, runtime, serverId])
|
||||
|
||||
const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces
|
||||
const isInitialLoad = isLoading && projects.length === 0
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./use-speechmatics-audio.native";
|
||||
@@ -1,274 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
initialize,
|
||||
useMicrophonePermissions,
|
||||
toggleRecording,
|
||||
tearDown,
|
||||
useExpoTwoWayAudioEventListener,
|
||||
type MicrophoneDataCallback,
|
||||
type VolumeLevelCallback,
|
||||
} from "@boudra/expo-two-way-audio";
|
||||
|
||||
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
||||
|
||||
export interface SpeechmaticsAudioConfig {
|
||||
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** When true, stream microphone PCM continuously without VAD gating. */
|
||||
enableContinuousStreaming?: boolean;
|
||||
volumeThreshold: number; // Volume threshold for speech detection (0-1)
|
||||
/** ms dip debounce before VAD transitions from confirmed speaking to non-speaking */
|
||||
confirmedDropGracePeriod?: number;
|
||||
silenceDuration: number; // ms of silence before ending segment
|
||||
speechConfirmationDuration: number; // ms of sustained speech before confirming
|
||||
detectionGracePeriod: number; // ms grace period for volume dips during detection
|
||||
}
|
||||
|
||||
export interface SpeechmaticsAudio {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
toggleMute: () => void;
|
||||
isActive: boolean;
|
||||
isSpeaking: boolean;
|
||||
isDetecting: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
segmentDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for audio capture with echo cancellation using Speechmatics expo-two-way-audio
|
||||
*/
|
||||
export function useSpeechmaticsAudio(
|
||||
config: SpeechmaticsAudioConfig
|
||||
): SpeechmaticsAudio {
|
||||
const [microphonePermission, requestMicrophonePermission] =
|
||||
useMicrophonePermissions();
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [segmentDuration, setSegmentDuration] = useState(0);
|
||||
|
||||
const enableContinuousStreaming = config.enableContinuousStreaming === true;
|
||||
|
||||
const isActiveRef = useRef(isActive);
|
||||
useEffect(() => {
|
||||
isActiveRef.current = isActive;
|
||||
}, [isActive]);
|
||||
|
||||
const isMutedRef = useRef(isMuted);
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted;
|
||||
}, [isMuted]);
|
||||
|
||||
const callbacksRef = useRef({
|
||||
onAudioSegment: config.onAudioSegment,
|
||||
onSpeechStart: config.onSpeechStart,
|
||||
onSpeechEnd: config.onSpeechEnd,
|
||||
});
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onAudioSegment: config.onAudioSegment,
|
||||
onSpeechStart: config.onSpeechStart,
|
||||
onSpeechEnd: config.onSpeechEnd,
|
||||
};
|
||||
}, [config.onAudioSegment, config.onSpeechStart, config.onSpeechEnd]);
|
||||
|
||||
const segmenterRef = useRef<SpeechSegmenter | null>(null);
|
||||
if (segmenterRef.current === null) {
|
||||
segmenterRef.current = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming,
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriod,
|
||||
silenceDurationMs: config.silenceDuration,
|
||||
speechConfirmationMs: config.speechConfirmationDuration,
|
||||
detectionGracePeriodMs: config.detectionGracePeriod,
|
||||
},
|
||||
{
|
||||
onAudioSegment: (segment) => callbacksRef.current.onAudioSegment?.(segment),
|
||||
onSpeechStart: () => callbacksRef.current.onSpeechStart?.(),
|
||||
onSpeechEnd: () => callbacksRef.current.onSpeechEnd?.(),
|
||||
onDetectingChange: (next) => setIsDetecting(next),
|
||||
onSpeakingChange: (next) => setIsSpeaking(next),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
segmenterRef.current?.updateConfig({
|
||||
enableContinuousStreaming,
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriod,
|
||||
silenceDurationMs: config.silenceDuration,
|
||||
speechConfirmationMs: config.speechConfirmationDuration,
|
||||
detectionGracePeriodMs: config.detectionGracePeriod,
|
||||
});
|
||||
}, [
|
||||
enableContinuousStreaming,
|
||||
config.volumeThreshold,
|
||||
config.confirmedDropGracePeriod,
|
||||
config.silenceDuration,
|
||||
config.speechConfirmationDuration,
|
||||
config.detectionGracePeriod,
|
||||
]);
|
||||
|
||||
// Update segment duration timer
|
||||
useEffect(() => {
|
||||
if (!isDetecting && !isSpeaking) {
|
||||
setSegmentDuration(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = segmenterRef.current?.getSpeechDetectionStartMs() ?? Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
setSegmentDuration(elapsed);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isDetecting, isSpeaking]);
|
||||
|
||||
// Listen to microphone data
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
useCallback<MicrophoneDataCallback>(
|
||||
(event) => {
|
||||
if (!isActiveRef.current || isMutedRef.current) return;
|
||||
|
||||
const pcmData: Uint8Array = event.data;
|
||||
segmenterRef.current?.pushPcmChunk(pcmData);
|
||||
},
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
// Listen to volume level for VAD
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
useCallback<VolumeLevelCallback>(
|
||||
(event) => {
|
||||
if (!isActiveRef.current) return;
|
||||
|
||||
const volumeLevel: number = event.data;
|
||||
setVolume(volumeLevel);
|
||||
|
||||
if (isMutedRef.current) return;
|
||||
segmenterRef.current?.pushVolumeLevel(volumeLevel, Date.now());
|
||||
},
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const ensureMicrophonePermission = useCallback(async () => {
|
||||
let permissionStatus = microphonePermission;
|
||||
|
||||
if (!permissionStatus?.granted) {
|
||||
try {
|
||||
permissionStatus = await requestMicrophonePermission();
|
||||
} catch (err) {
|
||||
throw new Error("Failed to request microphone permission");
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionStatus?.granted) {
|
||||
throw new Error(
|
||||
"Microphone permission is required to capture audio. Please enable microphone access in system settings."
|
||||
);
|
||||
}
|
||||
}, [microphonePermission, requestMicrophonePermission]);
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (isActive) {
|
||||
console.log("[SpeechmaticsAudio] Already active");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
|
||||
// Initialize audio if not already initialized
|
||||
if (!audioInitialized) {
|
||||
console.log("[SpeechmaticsAudio] Initializing audio...");
|
||||
await initialize();
|
||||
setAudioInitialized(true);
|
||||
console.log("[SpeechmaticsAudio] Audio initialized");
|
||||
}
|
||||
|
||||
console.log("[SpeechmaticsAudio] Starting audio capture...");
|
||||
|
||||
// Start recording
|
||||
toggleRecording(true);
|
||||
|
||||
setIsActive(true);
|
||||
console.log("[SpeechmaticsAudio] Audio capture started successfully");
|
||||
} catch (error) {
|
||||
console.error("[SpeechmaticsAudio] Start error:", error);
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
config.onError?.(err);
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
console.log("[SpeechmaticsAudio] Stopping audio capture...");
|
||||
|
||||
// Stop recording
|
||||
if (isActive) {
|
||||
toggleRecording(false);
|
||||
}
|
||||
|
||||
segmenterRef.current?.stop(Date.now());
|
||||
|
||||
// Tear down audio session
|
||||
if (audioInitialized) {
|
||||
tearDown();
|
||||
setAudioInitialized(false);
|
||||
console.log("[SpeechmaticsAudio] Audio torn down");
|
||||
}
|
||||
|
||||
// Reset state
|
||||
segmenterRef.current?.reset();
|
||||
setIsActive(false);
|
||||
setIsSpeaking(false);
|
||||
setIsDetecting(false);
|
||||
setVolume(0);
|
||||
setIsMuted(false);
|
||||
|
||||
console.log("[SpeechmaticsAudio] Audio capture stopped");
|
||||
}
|
||||
|
||||
function toggleMute(): void {
|
||||
setIsMuted((prev) => {
|
||||
const newMuted = !prev;
|
||||
console.log("[SpeechmaticsAudio] Mute toggled:", newMuted);
|
||||
|
||||
if (newMuted) {
|
||||
// Clear any ongoing speech detection/speaking state
|
||||
segmenterRef.current?.reset();
|
||||
setIsSpeaking(false);
|
||||
setIsDetecting(false);
|
||||
}
|
||||
|
||||
return newMuted;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
isActive,
|
||||
isSpeaking,
|
||||
isDetecting,
|
||||
isMuted,
|
||||
volume,
|
||||
segmentDuration,
|
||||
};
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
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;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** When true, stream microphone PCM continuously without VAD gating. */
|
||||
enableContinuousStreaming?: boolean;
|
||||
volumeThreshold: number; // 0-1
|
||||
/** ms dip debounce before VAD transitions from confirmed speaking to non-speaking */
|
||||
confirmedDropGracePeriod?: number;
|
||||
silenceDuration: number; // ms of silence before ending segment
|
||||
speechConfirmationDuration: number; // ms of sustained speech before confirming
|
||||
detectionGracePeriod: number; // ms grace period for volume dips during detection
|
||||
}
|
||||
|
||||
export interface SpeechmaticsAudio {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
toggleMute: () => void;
|
||||
isActive: boolean;
|
||||
isSpeaking: boolean;
|
||||
isDetecting: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
segmentDuration: number;
|
||||
}
|
||||
|
||||
const getAudioContextCtor = (): (typeof AudioContext) | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const ctor =
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
return ctor ?? null;
|
||||
};
|
||||
|
||||
const floatToInt16 = (sample: number): number => {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
};
|
||||
|
||||
const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => {
|
||||
if (input.length === 0) {
|
||||
return new Int16Array(0);
|
||||
}
|
||||
if (inputRate === outputRate) {
|
||||
const out = new Int16Array(input.length);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
out[i] = floatToInt16(input[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
out[i] = floatToInt16(sample);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): SpeechmaticsAudio {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [segmentDuration, setSegmentDuration] = useState(0);
|
||||
|
||||
const enableContinuousStreaming = config.enableContinuousStreaming === true;
|
||||
|
||||
const callbacksRef = useRef({
|
||||
onAudioSegment: config.onAudioSegment,
|
||||
onSpeechStart: config.onSpeechStart,
|
||||
onSpeechEnd: config.onSpeechEnd,
|
||||
onError: config.onError,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onAudioSegment: config.onAudioSegment,
|
||||
onSpeechStart: config.onSpeechStart,
|
||||
onSpeechEnd: config.onSpeechEnd,
|
||||
onError: config.onError,
|
||||
};
|
||||
}, [config.onAudioSegment, config.onSpeechStart, config.onSpeechEnd, config.onError]);
|
||||
|
||||
const segmenterRef = useRef<SpeechSegmenter | null>(null);
|
||||
if (segmenterRef.current === null) {
|
||||
segmenterRef.current = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming,
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriod,
|
||||
silenceDurationMs: config.silenceDuration,
|
||||
speechConfirmationMs: config.speechConfirmationDuration,
|
||||
detectionGracePeriodMs: config.detectionGracePeriod,
|
||||
},
|
||||
{
|
||||
onAudioSegment: (segment) => callbacksRef.current.onAudioSegment?.(segment),
|
||||
onSpeechStart: () => callbacksRef.current.onSpeechStart?.(),
|
||||
onSpeechEnd: () => callbacksRef.current.onSpeechEnd?.(),
|
||||
onDetectingChange: (next) => setIsDetecting(next),
|
||||
onSpeakingChange: (next) => setIsSpeaking(next),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
segmenterRef.current?.updateConfig({
|
||||
enableContinuousStreaming,
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriod,
|
||||
silenceDurationMs: config.silenceDuration,
|
||||
speechConfirmationMs: config.speechConfirmationDuration,
|
||||
detectionGracePeriodMs: config.detectionGracePeriod,
|
||||
});
|
||||
}, [
|
||||
enableContinuousStreaming,
|
||||
config.volumeThreshold,
|
||||
config.confirmedDropGracePeriod,
|
||||
config.silenceDuration,
|
||||
config.speechConfirmationDuration,
|
||||
config.detectionGracePeriod,
|
||||
]);
|
||||
|
||||
const refs = useRef<{
|
||||
started: boolean;
|
||||
stream: MediaStream | null;
|
||||
context: AudioContext | null;
|
||||
source: MediaStreamAudioSourceNode | null;
|
||||
processor: ScriptProcessorNode | null;
|
||||
gain: GainNode | null;
|
||||
}>({
|
||||
started: false,
|
||||
stream: null,
|
||||
context: null,
|
||||
source: null,
|
||||
processor: null,
|
||||
gain: null,
|
||||
});
|
||||
|
||||
const isMutedRef = useRef(isMuted);
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted;
|
||||
}, [isMuted]);
|
||||
|
||||
const vadConfigRef = useRef({
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
});
|
||||
useEffect(() => {
|
||||
vadConfigRef.current = {
|
||||
volumeThreshold: config.volumeThreshold,
|
||||
};
|
||||
}, [config.volumeThreshold]);
|
||||
|
||||
const vadLogRef = useRef({
|
||||
lastLogMs: 0,
|
||||
lastSpeaking: false,
|
||||
lastDetecting: false,
|
||||
});
|
||||
|
||||
// Update segment duration timer
|
||||
useEffect(() => {
|
||||
if (!isDetecting && !isSpeaking) {
|
||||
setSegmentDuration(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = segmenterRef.current?.getSpeechDetectionStartMs() ?? Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setSegmentDuration(Date.now() - startTime);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isDetecting, isSpeaking]);
|
||||
|
||||
const stopInternal = useCallback(async () => {
|
||||
refs.current.started = false;
|
||||
|
||||
try {
|
||||
refs.current.processor?.disconnect();
|
||||
refs.current.source?.disconnect();
|
||||
refs.current.gain?.disconnect();
|
||||
} catch {
|
||||
// best-effort teardown
|
||||
}
|
||||
|
||||
if (refs.current.stream) {
|
||||
for (const track of refs.current.stream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context = refs.current.context;
|
||||
if (context && context.state !== "closed") {
|
||||
try {
|
||||
await context.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
refs.current.stream = null;
|
||||
refs.current.context = null;
|
||||
refs.current.source = null;
|
||||
refs.current.processor = null;
|
||||
refs.current.gain = null;
|
||||
|
||||
segmenterRef.current?.stop(Date.now());
|
||||
setIsActive(false);
|
||||
setIsSpeaking(false);
|
||||
setIsDetecting(false);
|
||||
setVolume(0);
|
||||
setIsMuted(false);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (refs.current.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingNavigator =
|
||||
typeof navigator === "undefined" ||
|
||||
!navigator.mediaDevices ||
|
||||
typeof navigator.mediaDevices.getUserMedia !== "function";
|
||||
|
||||
const secureContext =
|
||||
typeof window !== "undefined" && typeof window.isSecureContext === "boolean"
|
||||
? 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");
|
||||
}
|
||||
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) {
|
||||
throw new Error("AudioContext unavailable");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
if (context.state === "suspended") {
|
||||
await context.resume();
|
||||
}
|
||||
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const processor = context.createScriptProcessor(4096, 1, 1);
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 0;
|
||||
|
||||
refs.current = {
|
||||
started: true,
|
||||
stream,
|
||||
context,
|
||||
source,
|
||||
processor,
|
||||
gain,
|
||||
};
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!refs.current.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const sample = input[i];
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
|
||||
const normalized = Math.min(1, Math.max(0, rms * 2));
|
||||
setVolume(normalized);
|
||||
|
||||
if (isMutedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
segmenterRef.current?.pushVolumeLevel(normalized, nowMs);
|
||||
const segmenter = segmenterRef.current;
|
||||
if (segmenter) {
|
||||
const speakingNow = segmenter.getIsSpeaking();
|
||||
const detectingNow = segmenter.getIsDetecting();
|
||||
const shouldLog =
|
||||
nowMs - vadLogRef.current.lastLogMs >= 150 ||
|
||||
speakingNow !== vadLogRef.current.lastSpeaking ||
|
||||
detectingNow !== vadLogRef.current.lastDetecting;
|
||||
if (shouldLog) {
|
||||
const threshold = vadConfigRef.current.volumeThreshold;
|
||||
const releaseThreshold = segmenter.getVolumeReleaseThreshold();
|
||||
console.log("[Voice][Web][VAD] level", {
|
||||
volume: Number(normalized.toFixed(3)),
|
||||
threshold: Number(threshold.toFixed(3)),
|
||||
releaseThreshold: Number(releaseThreshold.toFixed(3)),
|
||||
confirmedDropGraceMs: config.confirmedDropGracePeriod ?? 250,
|
||||
isSpeaking: speakingNow,
|
||||
isDetecting: detectingNow,
|
||||
});
|
||||
vadLogRef.current.lastLogMs = nowMs;
|
||||
vadLogRef.current.lastSpeaking = speakingNow;
|
||||
vadLogRef.current.lastDetecting = detectingNow;
|
||||
}
|
||||
}
|
||||
|
||||
const pcm16 = resampleToPcm16(input, context.sampleRate, 16000);
|
||||
const pcmBytes = new Uint8Array(pcm16.buffer, pcm16.byteOffset, pcm16.byteLength);
|
||||
segmenterRef.current?.pushPcmChunk(pcmBytes);
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
|
||||
setIsActive(true);
|
||||
return;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
callbacksRef.current.onError?.(err);
|
||||
await stopInternal();
|
||||
throw err;
|
||||
}
|
||||
}, [stopInternal]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
await stopInternal();
|
||||
}, [stopInternal]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setIsMuted((prev) => {
|
||||
const nextMuted = !prev;
|
||||
if (nextMuted) {
|
||||
segmenterRef.current?.reset();
|
||||
setIsSpeaking(false);
|
||||
setIsDetecting(false);
|
||||
}
|
||||
return nextMuted;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (refs.current.started) {
|
||||
void stopInternal();
|
||||
}
|
||||
};
|
||||
}, [stopInternal]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
isActive,
|
||||
isSpeaking,
|
||||
isDetecting,
|
||||
isMuted,
|
||||
volume,
|
||||
segmentDuration,
|
||||
}),
|
||||
[start, stop, toggleMute, isActive, isSpeaking, isDetecting, isMuted, volume, segmentDuration]
|
||||
);
|
||||
}
|
||||
28
packages/app/src/polyfills/screen-orientation.ts
Normal file
28
packages/app/src/polyfills/screen-orientation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function polyfillScreenOrientation() {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof screen === "undefined" ||
|
||||
screen.orientation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(screen, "orientation", {
|
||||
value: {
|
||||
get type() {
|
||||
return window.innerWidth > window.innerHeight
|
||||
? "landscape-primary"
|
||||
: "portrait-primary";
|
||||
},
|
||||
get angle() {
|
||||
return 0;
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
@@ -72,6 +72,12 @@ class FakeDaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
return { rttMs: 0 };
|
||||
}
|
||||
|
||||
setReconnectEnabled(_enabled: boolean): void {}
|
||||
|
||||
setConnectionState(next: ConnectionState): void {
|
||||
this.state = next;
|
||||
if (next.status === "disconnected") {
|
||||
@@ -176,12 +182,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
return {
|
||||
serverId: input?.serverId ?? "srv_test",
|
||||
label: input?.label ?? "test host",
|
||||
lifecycle: input?.lifecycle ?? {
|
||||
managed: false,
|
||||
managedRuntimeId: null,
|
||||
managedRuntimeVersion: null,
|
||||
associatedServerId: null,
|
||||
},
|
||||
lifecycle: input?.lifecycle ?? {},
|
||||
connections: input?.connections ?? [direct, relay],
|
||||
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
||||
createdAt: input?.createdAt ?? new Date(0).toISOString(),
|
||||
@@ -199,7 +200,7 @@ function makeDeps(
|
||||
createdClients.push(client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async ({ connection }) => {
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
const value = latencyByConnectionId[connection.id];
|
||||
if (value instanceof Error) {
|
||||
throw value;
|
||||
@@ -207,7 +208,16 @@ function makeDeps(
|
||||
if (typeof value !== "number") {
|
||||
throw new Error(`missing latency for ${connection.id}`);
|
||||
}
|
||||
return value;
|
||||
const client = new FakeDaemonClient();
|
||||
client.connectCalls = 1;
|
||||
client.setConnectionState({ status: "connected" });
|
||||
client.ping = async () => ({ rttMs: value });
|
||||
createdClients.push(client);
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
};
|
||||
@@ -227,8 +237,24 @@ function createDeferred<T>() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
||||
const client = new FakeDaemonClient();
|
||||
client.connectCalls = 1;
|
||||
client.setConnectionState({ status: "connected" });
|
||||
client.ping = async () => ({ rttMs: latencyMs });
|
||||
return client;
|
||||
}
|
||||
|
||||
function clearProbeBackoff(controller: HostRuntimeController): void {
|
||||
(
|
||||
controller as unknown as {
|
||||
connectionLastProbedAt: Map<string, number>;
|
||||
}
|
||||
).connectionLastProbedAt.clear();
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("keeps known hosts in connecting when client reports idle during connect", async () => {
|
||||
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
@@ -241,7 +267,7 @@ describe("HostRuntimeController", () => {
|
||||
const idleClient = new FakeDaemonClient();
|
||||
const deps: HostRuntimeControllerDeps = {
|
||||
createClient: () => idleClient as unknown as DaemonClient,
|
||||
measureLatency: async () => {
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
@@ -256,7 +282,11 @@ describe("HostRuntimeController", () => {
|
||||
// Intentionally do not emit a connected state; stay in idle.
|
||||
};
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
await (
|
||||
controller as unknown as {
|
||||
switchToConnection: (input: { connectionId: string }) => Promise<void>;
|
||||
}
|
||||
).switchToConnection({ connectionId: "direct:lan:6767" });
|
||||
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
|
||||
@@ -282,18 +312,24 @@ describe("HostRuntimeController", () => {
|
||||
seenClientIds.push(clientId);
|
||||
return fakeClient as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => 10,
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientId: async () => "cid_runtime_stable",
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
await (
|
||||
controller as unknown as {
|
||||
switchToConnection: (input: { connectionId: string }) => Promise<void>;
|
||||
}
|
||||
).switchToConnection({ connectionId: "direct:lan:6767" });
|
||||
|
||||
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
});
|
||||
|
||||
it("selects the lowest-latency connection on startup", async () => {
|
||||
it("adopts the first successful probe on startup", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
@@ -308,10 +344,62 @@ describe("HostRuntimeController", () => {
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient);
|
||||
expect(clients[0]?.connectCalls).toBe(1);
|
||||
expect(clients[1]?.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("activates the first successful probe without waiting for slower probes", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const slowPing = createDeferred<number>();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("should adopt the probe client");
|
||||
},
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
const client = makeConnectedProbeClient(
|
||||
connection.id === "direct:lan:6767" ? 12 : 30
|
||||
);
|
||||
if (connection.id === "relay:relay.paseo.sh:443") {
|
||||
client.ping = async () => ({ rttMs: await slowPing.promise });
|
||||
}
|
||||
clients.push(client);
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
const probeCycle = controller.runProbeCycleNow();
|
||||
|
||||
const timeoutAt = Date.now() + 200;
|
||||
while (Date.now() < timeoutAt) {
|
||||
const snapshot = controller.getSnapshot();
|
||||
if (
|
||||
snapshot.activeConnectionId === "direct:lan:6767" &&
|
||||
snapshot.connectionStatus === "online"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
|
||||
slowPing.resolve(30);
|
||||
await probeCycle;
|
||||
});
|
||||
|
||||
it("fails over when active connection becomes unavailable", async () => {
|
||||
@@ -328,17 +416,19 @@ describe("HostRuntimeController", () => {
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(clients).toHaveLength(1);
|
||||
const initialClient = controller.getSnapshot().client;
|
||||
expect(initialClient).toBeTruthy();
|
||||
|
||||
latencies["direct:lan:6767"] = new Error("direct unavailable");
|
||||
latencies["relay:relay.paseo.sh:443"] = 42;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(clients[0]?.closeCalls).toBe(1);
|
||||
expect(snapshot.client).not.toBe(initialClient);
|
||||
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("switches only after the faster alternative wins consecutive probes", async () => {
|
||||
@@ -358,15 +448,24 @@ describe("HostRuntimeController", () => {
|
||||
|
||||
latencies["direct:lan:6767"] = 95;
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(clients).toHaveLength(2);
|
||||
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
|
||||
for (let index = 0; index < 6 && !switched; index += 1) {
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
switched =
|
||||
controller.getSnapshot().activeConnectionId ===
|
||||
"relay:relay.paseo.sh:443";
|
||||
}
|
||||
expect(switched).toBe(true);
|
||||
expect(controller.getSnapshot().client).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not switch on a transient latency spike", async () => {
|
||||
@@ -386,24 +485,35 @@ describe("HostRuntimeController", () => {
|
||||
|
||||
latencies["direct:lan:6767"] = 100;
|
||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 20;
|
||||
latencies["relay:relay.paseo.sh:443"] = 90;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 100;
|
||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
|
||||
for (let index = 0; index < 6 && !switched; index += 1) {
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
switched =
|
||||
controller.getSnapshot().activeConnectionId ===
|
||||
"relay:relay.paseo.sh:443";
|
||||
}
|
||||
expect(switched).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes one snapshot with active connection and status from same source", async () => {
|
||||
@@ -609,7 +719,11 @@ describe("HostRuntimeController", () => {
|
||||
createdClients.push(client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => 10,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(10) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
@@ -686,21 +800,32 @@ describe("HostRuntimeController", () => {
|
||||
host,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
measureLatency: async () => {
|
||||
connectToDaemon: async ({ host }) => {
|
||||
probeCalls += 1;
|
||||
if (probeCalls === 1) {
|
||||
return await slowProbe.promise;
|
||||
}
|
||||
if (probeCalls === 2) {
|
||||
return await fastProbe.promise;
|
||||
}
|
||||
throw new Error("unexpected probe call");
|
||||
const client = new FakeDaemonClient();
|
||||
client.connectCalls = 1;
|
||||
client.setConnectionState({ status: "connected" });
|
||||
client.ping = async () => {
|
||||
if (probeCalls === 1) {
|
||||
return { rttMs: await slowProbe.promise };
|
||||
}
|
||||
if (probeCalls === 2) {
|
||||
return { rttMs: await fastProbe.promise };
|
||||
}
|
||||
throw new Error("unexpected probe call");
|
||||
};
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
const first = controller.runProbeCycleNow();
|
||||
clearProbeBackoff(controller);
|
||||
const second = controller.runProbeCycleNow();
|
||||
|
||||
fastProbe.resolve(12);
|
||||
@@ -724,7 +849,7 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps active client generation stable while overlapping probes run", async () => {
|
||||
it("keeps active client generation stable during background probe cycles", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
@@ -734,10 +859,7 @@ describe("HostRuntimeController", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const slowProbe = createDeferred<number>();
|
||||
const fastProbe = createDeferred<number>();
|
||||
const createdClients: FakeDaemonClient[] = [];
|
||||
let probeCalls = 0;
|
||||
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
@@ -747,18 +869,13 @@ describe("HostRuntimeController", () => {
|
||||
createdClients.push(client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => {
|
||||
probeCalls += 1;
|
||||
if (probeCalls === 1) {
|
||||
return 10;
|
||||
}
|
||||
if (probeCalls === 2) {
|
||||
return await slowProbe.promise;
|
||||
}
|
||||
if (probeCalls === 3) {
|
||||
return await fastProbe.promise;
|
||||
}
|
||||
return 10;
|
||||
connectToDaemon: async ({ host }) => {
|
||||
const client = makeConnectedProbeClient(10);
|
||||
return {
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
@@ -768,24 +885,13 @@ describe("HostRuntimeController", () => {
|
||||
const activeClientBeforeProbes = controller.getSnapshot().client;
|
||||
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
|
||||
|
||||
const first = controller.runProbeCycleNow();
|
||||
const second = controller.runProbeCycleNow();
|
||||
|
||||
fastProbe.resolve(12);
|
||||
await second;
|
||||
clearProbeBackoff(controller);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
|
||||
expect(controller.getSnapshot().clientGeneration).toBe(
|
||||
generationBeforeProbes
|
||||
);
|
||||
|
||||
slowProbe.resolve(999);
|
||||
await first;
|
||||
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
|
||||
expect(controller.getSnapshot().clientGeneration).toBe(
|
||||
generationBeforeProbes
|
||||
);
|
||||
expect(createdClients).toHaveLength(1);
|
||||
expect(createdClients[0]?.closeCalls).toBe(0);
|
||||
expect(createdClients).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -801,18 +907,22 @@ describe("HostRuntimeStore", () => {
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
fakeClient as unknown as DaemonClient
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
|
||||
@@ -837,9 +947,9 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("defers directory bootstrap until session store is initialized for that server", async () => {
|
||||
it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_deferred",
|
||||
serverId: "srv_no_session",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
@@ -849,26 +959,22 @@ describe("HostRuntimeStore", () => {
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
);
|
||||
|
||||
const timeoutAt = Date.now() + 600;
|
||||
const timeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
@@ -877,12 +983,11 @@ describe("HostRuntimeStore", () => {
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
filter: { includeArchived: true },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_deferred" },
|
||||
subscribe: { subscriptionId: "app:srv_no_session" },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
|
||||
@@ -897,6 +1002,7 @@ describe("HostRuntimeStore", () => {
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
@@ -928,15 +1034,18 @@ describe("HostRuntimeStore", () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
fakeClient as unknown as DaemonClient
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
|
||||
@@ -996,18 +1105,22 @@ describe("HostRuntimeStore", () => {
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
fakeClient as unknown as DaemonClient
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
|
||||
@@ -1046,7 +1159,7 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("surfaces startup failures as error instead of leaving host idle", async () => {
|
||||
it("records unavailable startup probes when no connection can be established", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
@@ -1061,7 +1174,7 @@ describe("HostRuntimeStore", () => {
|
||||
createClient: () => {
|
||||
throw new Error("create client failed");
|
||||
},
|
||||
measureLatency: async () => {
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
@@ -1071,12 +1184,51 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([host]);
|
||||
let snapshot = store.getSnapshot(host.serverId);
|
||||
const timeoutAt = Date.now() + 100;
|
||||
while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) {
|
||||
while (
|
||||
snapshot?.probeByConnectionId.get("direct:lan:6767")?.status !== "unavailable" &&
|
||||
Date.now() < timeoutAt
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
snapshot = store.getSnapshot(host.serverId);
|
||||
}
|
||||
|
||||
expect(snapshot?.connectionStatus).toBe("error");
|
||||
expect(snapshot?.lastError).toBe("create client failed");
|
||||
expect(snapshot?.connectionStatus).toBe("connecting");
|
||||
expect(snapshot?.lastError).toBeNull();
|
||||
expect(snapshot?.probeByConnectionId.get("direct:lan:6767")).toEqual({
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renameHost updates label in memory", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
// upsertDirectConnection goes through setHostsAndSync, which both sets
|
||||
// this.hosts and syncs controllers — matching the real init path.
|
||||
await store.upsertDirectConnection({
|
||||
serverId: "srv_rename",
|
||||
endpoint: "lan:6767",
|
||||
label: "old name",
|
||||
});
|
||||
expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name");
|
||||
|
||||
// persistHosts may throw in test env (no AsyncStorage/window), but the
|
||||
// in-memory state should still be updated by setHostsAndSync.
|
||||
await store.renameHost("srv_rename", "new name").catch(() => undefined);
|
||||
|
||||
const renamed = store.getHosts().find((h) => h.serverId === "srv_rename");
|
||||
expect(renamed?.label).toBe("new name");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
consumePersistedPerfDiagnosticReports,
|
||||
getPerfDiagnosticsSnapshot,
|
||||
isPerfDiagnosticsEnabled,
|
||||
peekPersistedPerfDiagnosticReports,
|
||||
} from "./engine";
|
||||
|
||||
export function installPerfDiagnosticsDebugTools(): () => void {
|
||||
if (!isPerfDiagnosticsEnabled()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
(
|
||||
globalThis as {
|
||||
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
|
||||
snapshot: (limit?: number) => ReturnType<typeof getPerfDiagnosticsSnapshot>;
|
||||
consumeReports: () => Promise<
|
||||
Awaited<ReturnType<typeof consumePersistedPerfDiagnosticReports>>
|
||||
>;
|
||||
peekReports: () => Promise<
|
||||
Awaited<ReturnType<typeof peekPersistedPerfDiagnosticReports>>
|
||||
>;
|
||||
};
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = {
|
||||
snapshot: (limit?: number) => getPerfDiagnosticsSnapshot(limit ?? 120),
|
||||
consumeReports: () => consumePersistedPerfDiagnosticReports(),
|
||||
peekReports: () => peekPersistedPerfDiagnosticReports(),
|
||||
};
|
||||
|
||||
return () => {
|
||||
(
|
||||
globalThis as {
|
||||
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: unknown;
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = undefined;
|
||||
};
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
import { getNowMs } from "@/utils/perf";
|
||||
import type {
|
||||
PerfDiagnosticBreadcrumb,
|
||||
PerfDiagnosticFields,
|
||||
PerfDiagnosticsReport,
|
||||
PerfDiagnosticsSnapshot,
|
||||
RecordPerfDiagnosticMarkOptions,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "paseo:perf-diagnostics:v1";
|
||||
const LEGACY_STORAGE_KEY = "paseo:js-hang-diagnostics:v1";
|
||||
const MONITOR_INTERVAL_MS = 100;
|
||||
const STALL_THRESHOLD_MS = 250;
|
||||
const SAMPLE_EVERY_N = 40;
|
||||
const MAX_BREADCRUMBS = 600;
|
||||
const BREADCRUMBS_PER_REPORT = 220;
|
||||
const MAX_STORED_REPORTS = 20;
|
||||
|
||||
const state = {
|
||||
breadcrumbs: [] as PerfDiagnosticBreadcrumb[],
|
||||
sampleCursor: 0,
|
||||
monitorHandle: null as ReturnType<typeof setInterval> | null,
|
||||
monitorLastTickMs: 0,
|
||||
monitorScope: null as string | null,
|
||||
monitorRefCount: 0,
|
||||
loadLogged: false,
|
||||
persistQueue: Promise.resolve(),
|
||||
};
|
||||
|
||||
function isPrimitive(value: unknown): value is string | number | boolean | null {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): unknown {
|
||||
if (isPrimitive(value)) {
|
||||
if (typeof value === "string" && value.length > 180) {
|
||||
return `${value.slice(0, 180)}...`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[array:${value.length}]`;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
return `[map:${value.size}]`;
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
return `[set:${value.size}]`;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "[undefined]";
|
||||
}
|
||||
return "[object]";
|
||||
}
|
||||
|
||||
function sanitizeFields(
|
||||
fields?: PerfDiagnosticFields
|
||||
): PerfDiagnosticFields | undefined {
|
||||
if (!fields) {
|
||||
return undefined;
|
||||
}
|
||||
const next: PerfDiagnosticFields = {};
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (count >= 16) {
|
||||
next.__truncated__ = true;
|
||||
break;
|
||||
}
|
||||
next[key] = sanitizeFieldValue(value);
|
||||
count += 1;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function shouldEnableDiagnostics(): boolean {
|
||||
const globalFlag = (
|
||||
globalThis as {
|
||||
__PASEO_PERF_DIAGNOSTICS__?: unknown;
|
||||
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS__;
|
||||
const legacyFlag = (
|
||||
globalThis as {
|
||||
__PASEO_PERF_DIAGNOSTICS__?: unknown;
|
||||
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
|
||||
}
|
||||
).__PASEO_JS_HANG_DIAGNOSTICS__;
|
||||
if (typeof globalFlag === "boolean") {
|
||||
return globalFlag;
|
||||
}
|
||||
if (typeof legacyFlag === "boolean") {
|
||||
return legacyFlag;
|
||||
}
|
||||
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
return Platform.OS === "android" || isDev;
|
||||
}
|
||||
|
||||
function shouldSample(): boolean {
|
||||
state.sampleCursor += 1;
|
||||
if (state.sampleCursor >= SAMPLE_EVERY_N) {
|
||||
state.sampleCursor = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pushBreadcrumb(item: PerfDiagnosticBreadcrumb): void {
|
||||
state.breadcrumbs.push(item);
|
||||
if (state.breadcrumbs.length > MAX_BREADCRUMBS) {
|
||||
state.breadcrumbs.splice(0, state.breadcrumbs.length - MAX_BREADCRUMBS);
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseReports(raw: string | null): PerfDiagnosticsReport[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter(
|
||||
(entry) => entry && typeof entry === "object"
|
||||
) as PerfDiagnosticsReport[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readPersistedReports(): Promise<PerfDiagnosticsReport[]> {
|
||||
const stored = safeParseReports(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
if (stored.length > 0) {
|
||||
return stored;
|
||||
}
|
||||
const legacy = safeParseReports(await AsyncStorage.getItem(LEGACY_STORAGE_KEY));
|
||||
if (legacy.length > 0) {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
|
||||
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
async function persistReport(report: PerfDiagnosticsReport): Promise<void> {
|
||||
state.persistQueue = state.persistQueue
|
||||
.then(async () => {
|
||||
const existing = await readPersistedReports();
|
||||
existing.push(report);
|
||||
if (existing.length > MAX_STORED_REPORTS) {
|
||||
existing.splice(0, existing.length - MAX_STORED_REPORTS);
|
||||
}
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[PerfDiagnostics] Failed to persist report", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
await state.persistQueue;
|
||||
}
|
||||
|
||||
function buildReport(
|
||||
scope: string,
|
||||
atMs: number,
|
||||
lagMs: number
|
||||
): PerfDiagnosticsReport {
|
||||
const id = `${Math.round(atMs)}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
const breadcrumbs = state.breadcrumbs.slice(-BREADCRUMBS_PER_REPORT);
|
||||
const wallTimeMs = Date.now();
|
||||
return {
|
||||
id,
|
||||
scope,
|
||||
atMs,
|
||||
wallTimeMs,
|
||||
wallTimeIso: new Date(wallTimeMs).toISOString(),
|
||||
lagMs,
|
||||
platform: Platform.OS,
|
||||
breadcrumbs,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordPerfDiagnosticMark(
|
||||
name: string,
|
||||
fields?: PerfDiagnosticFields,
|
||||
options?: RecordPerfDiagnosticMarkOptions
|
||||
): void {
|
||||
if (!shouldEnableDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
if (!force && !shouldSample()) {
|
||||
return;
|
||||
}
|
||||
pushBreadcrumb({
|
||||
atMs: getNowMs(),
|
||||
kind: "mark",
|
||||
name,
|
||||
fields: sanitizeFields(fields),
|
||||
});
|
||||
}
|
||||
|
||||
export function installPerfDiagnosticsMonitor(scope: string): () => void {
|
||||
if (!shouldEnableDiagnostics()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (state.monitorRefCount === 0) {
|
||||
state.monitorScope = scope;
|
||||
state.monitorLastTickMs = getNowMs();
|
||||
state.monitorHandle = setInterval(() => {
|
||||
const nowMs = getNowMs();
|
||||
const lagMs = nowMs - state.monitorLastTickMs - MONITOR_INTERVAL_MS;
|
||||
if (lagMs >= STALL_THRESHOLD_MS) {
|
||||
const report = buildReport(state.monitorScope ?? scope, nowMs, lagMs);
|
||||
recordPerfDiagnosticMark(
|
||||
"perf.stall_detected",
|
||||
{
|
||||
scope: report.scope,
|
||||
lagMs: Math.round(lagMs),
|
||||
reportId: report.id,
|
||||
breadcrumbs: report.breadcrumbs.length,
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
console.warn("[PerfDiagnostics] JS stall detected", {
|
||||
scope: report.scope,
|
||||
lagMs: Math.round(lagMs),
|
||||
reportId: report.id,
|
||||
breadcrumbs: report.breadcrumbs.length,
|
||||
});
|
||||
void persistReport(report);
|
||||
}
|
||||
state.monitorLastTickMs = nowMs;
|
||||
}, MONITOR_INTERVAL_MS);
|
||||
}
|
||||
|
||||
state.monitorRefCount += 1;
|
||||
|
||||
if (!state.loadLogged) {
|
||||
state.loadLogged = true;
|
||||
void readPersistedReports()
|
||||
.then((reports) => {
|
||||
if (reports.length > 0) {
|
||||
console.warn("[PerfDiagnostics] Recovered persisted stall reports", {
|
||||
count: reports.length,
|
||||
latestReportId: reports[reports.length - 1]?.id ?? null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return () => {
|
||||
state.monitorRefCount = Math.max(0, state.monitorRefCount - 1);
|
||||
if (state.monitorRefCount === 0 && state.monitorHandle) {
|
||||
clearInterval(state.monitorHandle);
|
||||
state.monitorHandle = null;
|
||||
state.monitorScope = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function consumePersistedPerfDiagnosticReports(): Promise<
|
||||
PerfDiagnosticsReport[]
|
||||
> {
|
||||
const reports = await readPersistedReports();
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
return reports;
|
||||
}
|
||||
|
||||
export async function peekPersistedPerfDiagnosticReports(): Promise<
|
||||
PerfDiagnosticsReport[]
|
||||
> {
|
||||
return readPersistedReports();
|
||||
}
|
||||
|
||||
export function isPerfDiagnosticsEnabled(): boolean {
|
||||
return shouldEnableDiagnostics();
|
||||
}
|
||||
|
||||
export function getPerfDiagnosticBreadcrumbs(
|
||||
limit = 120
|
||||
): PerfDiagnosticBreadcrumb[] {
|
||||
if (limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
return state.breadcrumbs.slice(-Math.floor(limit));
|
||||
}
|
||||
|
||||
export function getPerfDiagnosticsSnapshot(limit = 120): PerfDiagnosticsSnapshot {
|
||||
return {
|
||||
enabled: shouldEnableDiagnostics(),
|
||||
monitorScope: state.monitorScope,
|
||||
breadcrumbCount: state.breadcrumbs.length,
|
||||
breadcrumbs: getPerfDiagnosticBreadcrumbs(limit),
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { DaemonClientDiagnosticsEvent } from "@server/client/daemon-client";
|
||||
import { recordPerfDiagnosticMark } from "./engine";
|
||||
|
||||
let fastTransportSampleCursor = 0;
|
||||
const transportRateByServer = new Map<
|
||||
string,
|
||||
{
|
||||
startedAtMs: number;
|
||||
messageCount: number;
|
||||
bytes: number;
|
||||
errorCount: number;
|
||||
slowCount: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function shouldSampleFastTransportEvent(): boolean {
|
||||
fastTransportSampleCursor += 1;
|
||||
if (fastTransportSampleCursor >= 50) {
|
||||
fastTransportSampleCursor = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function recordHostRuntimeCreateClient(params: {
|
||||
serverId: string;
|
||||
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
|
||||
endpoint: string;
|
||||
}): void {
|
||||
recordPerfDiagnosticMark(
|
||||
"host_runtime.create_client",
|
||||
{
|
||||
serverId: params.serverId,
|
||||
connectionType: params.connectionType,
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
|
||||
export function recordDaemonClientDiagnostics(
|
||||
serverId: string,
|
||||
event: DaemonClientDiagnosticsEvent
|
||||
): void {
|
||||
if (event.type === "transport_message_timing") {
|
||||
const nowWallTimeMs = Date.now();
|
||||
const current =
|
||||
transportRateByServer.get(serverId) ?? {
|
||||
startedAtMs: nowWallTimeMs,
|
||||
messageCount: 0,
|
||||
bytes: 0,
|
||||
errorCount: 0,
|
||||
slowCount: 0,
|
||||
};
|
||||
current.messageCount += 1;
|
||||
current.bytes += event.payloadBytes;
|
||||
if (event.outcome !== "ok") {
|
||||
current.errorCount += 1;
|
||||
}
|
||||
if (event.totalMs >= 8) {
|
||||
current.slowCount += 1;
|
||||
}
|
||||
if (nowWallTimeMs - current.startedAtMs >= 1000) {
|
||||
recordPerfDiagnosticMark(
|
||||
"daemon_client.message_rate",
|
||||
{
|
||||
serverId,
|
||||
windowMs: nowWallTimeMs - current.startedAtMs,
|
||||
messageCount: current.messageCount,
|
||||
bytes: current.bytes,
|
||||
errorCount: current.errorCount,
|
||||
slowCount: current.slowCount,
|
||||
},
|
||||
{ force: current.errorCount > 0 || current.slowCount > 0 }
|
||||
);
|
||||
current.startedAtMs = nowWallTimeMs;
|
||||
current.messageCount = 0;
|
||||
current.bytes = 0;
|
||||
current.errorCount = 0;
|
||||
current.slowCount = 0;
|
||||
}
|
||||
transportRateByServer.set(serverId, current);
|
||||
}
|
||||
|
||||
if (event.type === "transport_binary_frame") {
|
||||
if (event.payloadBytes < 16_384) {
|
||||
return;
|
||||
}
|
||||
recordPerfDiagnosticMark(
|
||||
"daemon_client.binary_frame",
|
||||
{
|
||||
serverId,
|
||||
channel: event.channel,
|
||||
messageType: event.messageType,
|
||||
payloadBytes: event.payloadBytes,
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSlow =
|
||||
event.totalMs >= 8 || event.parseMs >= 4 || event.validateMs >= 4;
|
||||
const isError = event.outcome !== "ok";
|
||||
if (!isSlow && !isError && !shouldSampleFastTransportEvent()) {
|
||||
return;
|
||||
}
|
||||
recordPerfDiagnosticMark(
|
||||
"daemon_client.transport_message",
|
||||
{
|
||||
serverId,
|
||||
messageType: event.messageType,
|
||||
outcome: event.outcome,
|
||||
payloadBytes: event.payloadBytes,
|
||||
parseMs: Math.round(event.parseMs * 100) / 100,
|
||||
validateMs: Math.round(event.validateMs * 100) / 100,
|
||||
totalMs: Math.round(event.totalMs * 100) / 100,
|
||||
},
|
||||
{ force: isSlow || isError }
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export type {
|
||||
PerfDiagnosticBreadcrumb,
|
||||
PerfDiagnosticFields,
|
||||
PerfDiagnosticsReport,
|
||||
PerfDiagnosticsSnapshot,
|
||||
PerfDiagnosticsReporter,
|
||||
RecordPerfDiagnosticMarkOptions,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
consumePersistedPerfDiagnosticReports,
|
||||
getPerfDiagnosticBreadcrumbs,
|
||||
getPerfDiagnosticsSnapshot,
|
||||
installPerfDiagnosticsMonitor,
|
||||
isPerfDiagnosticsEnabled,
|
||||
peekPersistedPerfDiagnosticReports,
|
||||
recordPerfDiagnosticMark,
|
||||
} from "./engine";
|
||||
|
||||
export { getPerfDiagnosticsReporter } from "./reporter";
|
||||
export { PerfDiagnosticsProvider } from "./perf-diagnostics-provider";
|
||||
export { usePerfDiagnostics } from "./use-perf-diagnostics";
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
import type { PerfDiagnosticsReporter, PerfDiagnosticsSnapshot } from "./types";
|
||||
|
||||
const EMPTY_SNAPSHOT: PerfDiagnosticsSnapshot = {
|
||||
enabled: false,
|
||||
monitorScope: null,
|
||||
breadcrumbCount: 0,
|
||||
breadcrumbs: [],
|
||||
};
|
||||
|
||||
const NOOP_REPORTER: PerfDiagnosticsReporter = {
|
||||
mark: () => {},
|
||||
installMonitor: () => () => {},
|
||||
consumeReports: async () => [],
|
||||
peekReports: async () => [],
|
||||
isEnabled: () => false,
|
||||
getSnapshot: () => EMPTY_SNAPSHOT,
|
||||
};
|
||||
|
||||
export const PerfDiagnosticsContext =
|
||||
createContext<PerfDiagnosticsReporter>(NOOP_REPORTER);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import { installPerfDiagnosticsDebugTools } from "./debug-tools";
|
||||
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
|
||||
import { getPerfDiagnosticsReporter } from "./reporter";
|
||||
|
||||
interface PerfDiagnosticsProviderProps {
|
||||
children: ReactNode;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function PerfDiagnosticsProvider({
|
||||
children,
|
||||
scope = "root_layout",
|
||||
}: PerfDiagnosticsProviderProps) {
|
||||
const reporter = useMemo(() => getPerfDiagnosticsReporter(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reporter.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeDebugTools = installPerfDiagnosticsDebugTools();
|
||||
const stopMonitor = reporter.installMonitor(scope);
|
||||
|
||||
void reporter
|
||||
.peekReports()
|
||||
.then((reports) => {
|
||||
if (reports.length === 0) {
|
||||
return;
|
||||
}
|
||||
const latest = reports[reports.length - 1];
|
||||
console.warn("[PerfDiagnostics] Loaded persisted stall reports", {
|
||||
count: reports.length,
|
||||
latestReportId: latest?.id ?? null,
|
||||
latestWallTime: latest?.wallTimeIso ?? null,
|
||||
latestLagMs: latest ? Math.round(latest.lagMs) : null,
|
||||
latestBreadcrumbs: latest?.breadcrumbs.length ?? null,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
removeDebugTools();
|
||||
stopMonitor();
|
||||
};
|
||||
}, [scope, reporter]);
|
||||
|
||||
return (
|
||||
<PerfDiagnosticsContext.Provider value={reporter}>
|
||||
{children}
|
||||
</PerfDiagnosticsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
consumePersistedPerfDiagnosticReports,
|
||||
getPerfDiagnosticsSnapshot,
|
||||
installPerfDiagnosticsMonitor,
|
||||
isPerfDiagnosticsEnabled,
|
||||
peekPersistedPerfDiagnosticReports,
|
||||
recordPerfDiagnosticMark,
|
||||
} from "./engine";
|
||||
import type { PerfDiagnosticsReporter } from "./types";
|
||||
|
||||
const reporter: PerfDiagnosticsReporter = {
|
||||
mark: recordPerfDiagnosticMark,
|
||||
installMonitor: installPerfDiagnosticsMonitor,
|
||||
consumeReports: consumePersistedPerfDiagnosticReports,
|
||||
peekReports: peekPersistedPerfDiagnosticReports,
|
||||
isEnabled: isPerfDiagnosticsEnabled,
|
||||
getSnapshot: getPerfDiagnosticsSnapshot,
|
||||
};
|
||||
|
||||
export function getPerfDiagnosticsReporter(): PerfDiagnosticsReporter {
|
||||
return reporter;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export type PerfDiagnosticFields = Record<string, unknown>;
|
||||
|
||||
export interface PerfDiagnosticBreadcrumb {
|
||||
atMs: number;
|
||||
kind: "mark" | "span";
|
||||
name: string;
|
||||
durationMs?: number;
|
||||
fields?: PerfDiagnosticFields;
|
||||
}
|
||||
|
||||
export interface PerfDiagnosticsReport {
|
||||
id: string;
|
||||
scope: string;
|
||||
atMs: number;
|
||||
wallTimeMs: number;
|
||||
wallTimeIso: string;
|
||||
lagMs: number;
|
||||
platform: string;
|
||||
breadcrumbs: PerfDiagnosticBreadcrumb[];
|
||||
}
|
||||
|
||||
export interface RecordPerfDiagnosticMarkOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PerfDiagnosticsSnapshot {
|
||||
enabled: boolean;
|
||||
monitorScope: string | null;
|
||||
breadcrumbCount: number;
|
||||
breadcrumbs: PerfDiagnosticBreadcrumb[];
|
||||
}
|
||||
|
||||
export interface PerfDiagnosticsReporter {
|
||||
mark: (
|
||||
name: string,
|
||||
fields?: PerfDiagnosticFields,
|
||||
options?: RecordPerfDiagnosticMarkOptions
|
||||
) => void;
|
||||
installMonitor: (scope: string) => () => void;
|
||||
consumeReports: () => Promise<PerfDiagnosticsReport[]>;
|
||||
peekReports: () => Promise<PerfDiagnosticsReport[]>;
|
||||
isEnabled: () => boolean;
|
||||
getSnapshot: (limit?: number) => PerfDiagnosticsSnapshot;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
|
||||
import type { PerfDiagnosticsReporter } from "./types";
|
||||
|
||||
export function usePerfDiagnostics(): PerfDiagnosticsReporter {
|
||||
return useContext(PerfDiagnosticsContext);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ExplorerSidebarAnimationProvider,
|
||||
} from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useHostRuntimeSession,
|
||||
@@ -30,11 +30,6 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
endNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
@@ -65,20 +60,11 @@ import {
|
||||
} from "./agent-ready-screen-bottom-anchor";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
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);
|
||||
}
|
||||
|
||||
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV || Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
console.log("[WebStickyBottom]", event, details);
|
||||
function logWebStickyBottom(
|
||||
_event: string,
|
||||
_details: Record<string, unknown>
|
||||
): void {
|
||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||
}
|
||||
|
||||
export function AgentReadyScreen({
|
||||
@@ -86,15 +72,17 @@ export function AgentReadyScreen({
|
||||
agentId,
|
||||
showExplorerSidebar = true,
|
||||
wrapWithExplorerSidebarProvider = true,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
showExplorerSidebar?: boolean;
|
||||
wrapWithExplorerSidebarProvider?: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const resolvedAgentId = agentId?.trim() || undefined;
|
||||
const resolvedServerId = serverId?.trim() || undefined;
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const runtimeServerId = resolvedServerId ?? "";
|
||||
const {
|
||||
snapshot: runtimeSnapshot,
|
||||
@@ -115,27 +103,6 @@ export function AgentReadyScreen({
|
||||
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
|
||||
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
|
||||
|
||||
const focusServerId = resolvedServerId;
|
||||
const navigationStatus = isRuntimeSessionAvailable
|
||||
? "ready"
|
||||
: "session_unavailable";
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!resolvedAgentId || !focusServerId) {
|
||||
return;
|
||||
}
|
||||
const navigationKey = buildAgentNavigationKey(
|
||||
focusServerId,
|
||||
resolvedAgentId
|
||||
);
|
||||
endNavigationTiming(navigationKey, {
|
||||
screen: "agent",
|
||||
status: navigationStatus,
|
||||
});
|
||||
}, [focusServerId, navigationStatus, resolvedAgentId])
|
||||
);
|
||||
|
||||
if (!resolvedServerId || !runtimeClient) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
@@ -155,6 +122,7 @@ export function AgentReadyScreen({
|
||||
isConnected={runtimeIsConnected}
|
||||
connectionStatus={connectionStatus}
|
||||
showExplorerSidebar={showExplorerSidebar}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -172,6 +140,7 @@ type AgentScreenContentProps = {
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
showExplorerSidebar: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
};
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
@@ -192,6 +161,7 @@ function AgentScreenContent({
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
showExplorerSidebar,
|
||||
onOpenWorkspaceFile,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
@@ -285,24 +255,11 @@ 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]);
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
|
||||
const stop = startPerfMonitor(scope);
|
||||
return stop;
|
||||
}, [serverId, agentId]);
|
||||
|
||||
// Swipe-left gesture to open explorer sidebar on mobile
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled: isMobile && mobileView === "agent",
|
||||
@@ -939,6 +896,7 @@ function AgentScreenContent({
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
checkoutStatusQueryKey,
|
||||
} from '@/hooks/use-checkout-status-query'
|
||||
import { useAllAgentsList } from '@/hooks/use-all-agents-list'
|
||||
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
||||
import { useHosts } from '@/runtime/host-runtime'
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions'
|
||||
import { shortenPath } from '@/utils/shorten-path'
|
||||
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
|
||||
@@ -138,7 +138,7 @@ function DraftAgentScreenContent({
|
||||
const { theme } = useUnistyles()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { daemons } = useDaemonRegistry()
|
||||
const daemons = useHosts()
|
||||
const runtime = getHostRuntimeStore()
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -21,6 +21,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
@@ -29,7 +30,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
||||
}, [isMobile, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
@@ -56,6 +57,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
userSelect: "none",
|
||||
},
|
||||
menuToggle: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
@@ -487,12 +488,8 @@ export default function SettingsScreen() {
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
const {
|
||||
daemons,
|
||||
isLoading: daemonLoading,
|
||||
updateHost,
|
||||
removeHost,
|
||||
removeConnection,
|
||||
} = useDaemonRegistry();
|
||||
daemons, renameHost, removeHost, removeConnection,
|
||||
} = { daemons: useHosts(), ...useHostMutations() };
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
@@ -503,7 +500,7 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
const isLoading = settingsLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = Platform.OS === "web";
|
||||
@@ -615,7 +612,7 @@ export default function SettingsScreen() {
|
||||
|
||||
try {
|
||||
setIsSavingEdit(true);
|
||||
await updateHost(editingServerId, { label: nextLabel });
|
||||
await renameHost(editingServerId, nextLabel);
|
||||
handleCloseEditDaemon();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to rename host", error);
|
||||
@@ -623,7 +620,7 @@ export default function SettingsScreen() {
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
||||
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]);
|
||||
|
||||
const handleRemoveConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
@@ -767,7 +764,7 @@ export default function SettingsScreen() {
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => setPendingNameHost(null)}
|
||||
onSave={(label) => {
|
||||
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
||||
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -8,25 +10,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
logo: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
status: {
|
||||
marginTop: theme.spacing[8],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.lg,
|
||||
},
|
||||
}));
|
||||
|
||||
export function StartupSplashScreen() {
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.status}>Starting up…</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -156,8 +156,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
const layoutItem = layout.items[index] ?? null;
|
||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||
const tooltipLabel =
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
@@ -172,11 +170,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID={`workspace-tab-tooltip-${tab.key}`}
|
||||
accessibilityRole="none"
|
||||
style={styles.tabTooltipTrigger}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
@@ -228,9 +222,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
selectable={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{renderedLabel}
|
||||
{tab.label}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
@@ -411,9 +407,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
tabTooltipTrigger: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -421,6 +414,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
tabHandle: {
|
||||
flexDirection: "row",
|
||||
@@ -428,6 +422,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
tabIcon: {
|
||||
flexShrink: 0,
|
||||
@@ -444,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
userSelect: "none",
|
||||
},
|
||||
tabLabelSkeleton: {
|
||||
width: 96,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AgentInputArea } from '@/components/agent-input-area'
|
||||
import { FileDropZone } from '@/components/file-drop-zone'
|
||||
import { AgentStreamView } from '@/components/agent-stream-view'
|
||||
import type { ImageAttachment } from '@/components/message-input'
|
||||
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
|
||||
import { useAgentFormState } from '@/hooks/use-agent-form-state'
|
||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||
@@ -30,6 +29,7 @@ type WorkspaceDraftAgentTabProps = {
|
||||
tabId: string
|
||||
draftId: string
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void
|
||||
onOpenWorkspaceFile: (input: { filePath: string }) => void
|
||||
}
|
||||
|
||||
export function WorkspaceDraftAgentTab({
|
||||
@@ -38,6 +38,7 @@ export function WorkspaceDraftAgentTab({
|
||||
tabId,
|
||||
draftId,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId)
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
||||
@@ -205,6 +206,7 @@ export function WorkspaceDraftAgentTab({
|
||||
agent={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
@@ -260,8 +262,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user