Merge origin/main into show-image-previews-in-optimistic-messages
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
name: Android APK Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Existing tag to build (e.g. v0.1.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-android-apk:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: read
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
registry-url: "https://npm.pkg.github.com"
|
||||||
|
scope: "@boudra"
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Expo and EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Android APK on EAS
|
||||||
|
id: eas_build
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd packages/app
|
||||||
|
|
||||||
|
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
|
||||||
|
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
|
||||||
|
|
||||||
|
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
|
||||||
|
if [ -z "$build_id" ]; then
|
||||||
|
echo "Failed to determine EAS build ID."
|
||||||
|
cat "$RUNNER_TEMP/eas-build.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Resolve APK artifact URL
|
||||||
|
id: artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd packages/app
|
||||||
|
|
||||||
|
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
|
||||||
|
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
|
||||||
|
|
||||||
|
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
|
||||||
|
if [ -z "$artifact_url" ]; then
|
||||||
|
echo "Failed to determine APK artifact URL."
|
||||||
|
cat "$RUNNER_TEMP/eas-build-view.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
asset_name="paseo-${RELEASE_TAG}-android.apk"
|
||||||
|
asset_path="$RUNNER_TEMP/$asset_name"
|
||||||
|
|
||||||
|
curl --fail --location --output "$asset_path" "$artifact_url"
|
||||||
|
|
||||||
|
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for GitHub release tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 90); do
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
|
echo "Found release for tag $RELEASE_TAG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Upload APK to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.9] - 2026-02-17
|
||||||
|
### Improved
|
||||||
|
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||||
|
- Reused provider availability checks for structured generation fallback selection.
|
||||||
|
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||||
|
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||||
|
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||||
|
|
||||||
|
## [0.1.8] - 2026-02-17
|
||||||
|
### Added
|
||||||
|
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||||
|
- Updated website hero copy to emphasize local execution.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||||
|
- Fixed Claude session identity preservation when reloading existing agents.
|
||||||
|
- Fixed combobox option behavior and related interactions.
|
||||||
|
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||||
|
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||||
|
|
||||||
|
## [0.1.7] - 2026-02-16
|
||||||
|
### Added
|
||||||
|
- Improved agent workspace flows with better directory suggestions.
|
||||||
|
- Added iOS TestFlight and Android app access request forms on the website.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||||
|
- Improved website app download and update guidance.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevented an initial desktop combobox `0,0` position flash.
|
||||||
|
- Fixed CLI version output issues.
|
||||||
|
- Hardened server runtime loading for local speech dependencies.
|
||||||
|
|
||||||
|
## [0.1.6] - 2026-02-16
|
||||||
|
### Notes
|
||||||
|
- No major visible product changes in this patch release.
|
||||||
|
|
||||||
|
## [0.1.5] - 2026-02-16
|
||||||
|
### Added
|
||||||
|
- Added terminal reattach support and better worktree terminal handling.
|
||||||
|
- Added global keyboard shortcut help in the app.
|
||||||
|
- Added sidebar host filtering and improved agent workflow controls.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved worktree setup visibility by streaming setup progress.
|
||||||
|
- Improved terminal streaming reliability and lifecycle handling.
|
||||||
|
- Preserved explorer tab state so context survives navigation better.
|
||||||
|
|
||||||
|
## [0.1.4] - 2026-02-14
|
||||||
|
### Added
|
||||||
|
- Added voice capability status reporting in the client.
|
||||||
|
- Added background local speech model downloads with runtime gating.
|
||||||
|
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||||
|
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved connection selection and agent hydration reliability.
|
||||||
|
- Improved timeline loading with cursor-based fetch behavior.
|
||||||
|
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||||
|
- Improved inline code rendering by auto-linkifying URLs.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||||
|
- Fixed stale relay client timer behavior.
|
||||||
|
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-02-12
|
||||||
|
### Added
|
||||||
|
- Added CLI onboarding command.
|
||||||
|
- Added CLI `--output-schema` support for structured agent output.
|
||||||
|
- Added CLI agent metadata update support for names and labels.
|
||||||
|
- Added provider availability detection with normalization of legacy default model IDs.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||||
|
- Added better voice interrupt handling with a speech-start grace period.
|
||||||
|
- Improved CLI defaults to list all non-archived agents by default.
|
||||||
|
- Improved website UX with clearer install CTA and privacy policy access.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-02-11
|
||||||
|
### Notes
|
||||||
|
- No major visible product changes in this patch release.
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-02-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial `0.1.x` release line.
|
||||||
@@ -109,7 +109,7 @@ Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package
|
|||||||
- `production` -> app name `Paseo`, package `sh.paseo`
|
- `production` -> app name `Paseo`, package `sh.paseo`
|
||||||
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
|
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
|
||||||
|
|
||||||
EAS profiles live in `packages/app/eas.json` as `development` and `production`.
|
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
|
||||||
|
|
||||||
`development` uses Android `debug`.
|
`development` uses Android `debug`.
|
||||||
|
|
||||||
@@ -136,6 +136,29 @@ npm run android:production
|
|||||||
|
|
||||||
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
|
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
|
||||||
|
|
||||||
|
### Cloud build + submit (EAS Workflows)
|
||||||
|
|
||||||
|
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||||
|
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||||
|
|
||||||
|
That workflow does:
|
||||||
|
- Build iOS with the `production` profile
|
||||||
|
- Build Android with the `production` profile
|
||||||
|
- Submit each build with the `production` submit profile
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent mobile workflow runs
|
||||||
|
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||||
|
|
||||||
|
# Inspect one run (jobs, status, outputs)
|
||||||
|
cd packages/app && npx eas workflow:view <run-id>
|
||||||
|
|
||||||
|
# Stream logs for all steps in one failed job
|
||||||
|
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||||
|
```
|
||||||
|
|
||||||
## Testing with Playwright MCP
|
## Testing with Playwright MCP
|
||||||
|
|
||||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||||
@@ -160,14 +183,22 @@ npm run release:patch
|
|||||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||||
npm run release:check
|
npm run release:check
|
||||||
npm run release:publish
|
npm run release:publish
|
||||||
npm run release:push # pushes HEAD and current version tag (triggers desktop release)
|
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
- `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 `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||||
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
|
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||||
|
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||||
|
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||||
|
|
||||||
|
Release completion checklist:
|
||||||
|
- `npm run release:patch` completes successfully.
|
||||||
|
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||||
|
- GitHub `Android APK Release` workflow for the same tag is green.
|
||||||
|
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||||
|
|
||||||
## Orchestrator Mode
|
## Orchestrator Mode
|
||||||
|
|
||||||
|
|||||||
@@ -17,77 +17,44 @@
|
|||||||
|
|
||||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||||
|
|
||||||
## Features
|
## Getting Started
|
||||||
|
|
||||||
- **Self-hosted:** The daemon runs on your laptop, home server, or VPS
|
|
||||||
- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface
|
|
||||||
- **Multi-host:** Connect to multiple daemons and see all your agents in one place
|
|
||||||
- **Voice input:** Dictate prompts when you're away from your keyboard
|
|
||||||
- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly
|
|
||||||
- **Cross-device:** iOS, Android, desktop, web, and CLI
|
|
||||||
- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app
|
|
||||||
- **Open source:** Free and open source under MIT license
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli && paseo
|
npm install -g @getpaseo/cli
|
||||||
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the app and connect to your daemon.
|
Then open the app and connect to your daemon.
|
||||||
|
|
||||||
## Local speech (STT/TTS)
|
For full setup and configuration, see:
|
||||||
|
- [Docs](https://paseo.sh/docs)
|
||||||
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`.
|
## Development
|
||||||
|
|
||||||
When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`).
|
Quick monorepo package map:
|
||||||
|
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||||
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
|
- `packages/desktop`: Tauri desktop app
|
||||||
|
- `packages/relay`: Relay package for remote connectivity
|
||||||
|
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run speech:download --workspace=@getpaseo/server
|
# run all local dev services
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# run individual surfaces
|
||||||
|
npm run dev:server
|
||||||
|
npm run dev:app
|
||||||
|
npm run dev:website
|
||||||
|
|
||||||
|
# repo-wide checks
|
||||||
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional configuration:
|
|
||||||
|
|
||||||
- `PASEO_SHERPA_ONNX_MODELS_DIR` (defaults to `~/.paseo/models/sherpa-onnx`)
|
|
||||||
- `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD` (`1` by default; set `0` to disable automatic downloads on daemon start)
|
|
||||||
- `PASEO_SHERPA_STT_PRESET` (`zipformer`, `paraformer`, or `parakeet` for NVIDIA Parakeet TDT v3)
|
|
||||||
- `PASEO_SHERPA_TTS_PRESET` (`pocket-tts` (Kyutai Pocket TTS), `kitten`, or `kokoro`)
|
|
||||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER` (`sherpa` or `openai`)
|
|
||||||
|
|
||||||
To see all supported local model IDs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run speech:models --workspace=@getpaseo/server
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional: run an end-to-end test that downloads real models and exercises streaming STT + streaming TTS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \
|
|
||||||
npx vitest run --workspace=@getpaseo/server src/server/speech/sherpa/speech-download.e2e.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
|
|
||||||
|
|
||||||
## Desktop releases
|
|
||||||
|
|
||||||
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run version:all:patch
|
|
||||||
npm run release:push
|
|
||||||
```
|
|
||||||
|
|
||||||
For the full package release flow, use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run release:patch
|
|
||||||
```
|
|
||||||
|
|
||||||
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
Generated
+43
-22
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -13681,6 +13681,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lezer-elixir": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/highlight": "^1.2.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lighthouse-logger": {
|
"node_modules/lighthouse-logger": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -14705,7 +14714,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/nan": {
|
"node_modules/nan": {
|
||||||
"version": "2.23.0",
|
"version": "2.23.0",
|
||||||
"license": "MIT"
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
@@ -14770,6 +14781,12 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"funding": [
|
"funding": [
|
||||||
@@ -14816,6 +14833,16 @@
|
|||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-pty": {
|
||||||
|
"version": "1.2.0-beta.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
|
||||||
|
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.26",
|
"version": "2.0.26",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -20335,7 +20362,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -20343,7 +20370,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/server": "0.1.4",
|
"@getpaseo/server": "0.1.13",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
@@ -20390,6 +20417,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -20446,11 +20474,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.4",
|
"@getpaseo/relay": "0.1.13",
|
||||||
"@getpaseo/server": "0.1.4",
|
"@getpaseo/server": "0.1.13",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -20500,14 +20528,14 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.6"
|
"@tauri-apps/cli": "^2.9.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -20523,12 +20551,12 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/relay": "0.1.4",
|
"@getpaseo/relay": "0.1.13",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/css": "^1.3.0",
|
"@lezer/css": "^1.3.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
@@ -20546,8 +20574,9 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
@@ -20791,14 +20820,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/server/node_modules/node-pty": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"nan": "^2.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/qs": {
|
"packages/server/node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
@@ -20879,7 +20900,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/server",
|
"packages/server",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
|
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Release Mobile
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_ios:
|
||||||
|
name: Build iOS
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
build_android:
|
||||||
|
name: Build Android
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: android
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit_ios:
|
||||||
|
name: Submit iOS
|
||||||
|
needs: [build_ios]
|
||||||
|
type: submit
|
||||||
|
params:
|
||||||
|
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit_android:
|
||||||
|
name: Submit Android
|
||||||
|
needs: [build_android]
|
||||||
|
type: submit
|
||||||
|
params:
|
||||||
|
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||||
|
profile: production
|
||||||
@@ -1,14 +1,46 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||||
|
|
||||||
|
function resolveSecretFile(params) {
|
||||||
|
const fromEnv = process.env[params.envKey];
|
||||||
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
||||||
|
return fromEnv.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath);
|
||||||
|
if (fs.existsSync(fallbackAbsolutePath)) {
|
||||||
|
return params.fallbackRelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
production: {
|
production: {
|
||||||
name: "Paseo",
|
name: "Paseo",
|
||||||
packageId: "sh.paseo",
|
packageId: "sh.paseo",
|
||||||
|
googleServicesFile: resolveSecretFile({
|
||||||
|
envKey: "GOOGLE_SERVICES_FILE_PROD",
|
||||||
|
fallbackRelativePath: "./.secrets/google-services.prod.json",
|
||||||
|
}),
|
||||||
|
googleServiceInfoPlist: resolveSecretFile({
|
||||||
|
envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD",
|
||||||
|
fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
development: {
|
development: {
|
||||||
name: "Paseo Debug",
|
name: "Paseo Debug",
|
||||||
packageId: "sh.paseo.debug",
|
packageId: "sh.paseo.debug",
|
||||||
|
googleServicesFile: resolveSecretFile({
|
||||||
|
envKey: "GOOGLE_SERVICES_FILE_DEBUG",
|
||||||
|
fallbackRelativePath: "./.secrets/google-services.debug.json",
|
||||||
|
}),
|
||||||
|
googleServiceInfoPlist: resolveSecretFile({
|
||||||
|
envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG",
|
||||||
|
fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +70,9 @@ export default {
|
|||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: variant.packageId,
|
bundleIdentifier: variant.packageId,
|
||||||
|
...(variant.googleServiceInfoPlist
|
||||||
|
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
@@ -57,6 +92,9 @@ export default {
|
|||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
|
...(variant.googleServicesFile
|
||||||
|
? { googleServicesFile: variant.googleServicesFile }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "single",
|
output: "single",
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ export default async function globalSetup() {
|
|||||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||||
PASEO_DICTATION_ENABLED: "0",
|
PASEO_DICTATION_ENABLED: "0",
|
||||||
PASEO_VOICE_MODE_ENABLED: "0",
|
PASEO_VOICE_MODE_ENABLED: "0",
|
||||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'development',
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||||
|
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||||
const closeBottomSheet = async () => {
|
const closeBottomSheet = async () => {
|
||||||
@@ -218,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
|
|
||||||
await closeWorktreeSheetIfOpen();
|
await closeWorktreeSheetIfOpen();
|
||||||
|
|
||||||
if (!(await input.isVisible())) {
|
const pickerInputVisible = async () =>
|
||||||
|
(await directorySearchInput.isVisible().catch(() => false)) ||
|
||||||
|
(await legacyInput.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
if (!(await pickerInputVisible())) {
|
||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectorySelect.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
if (!(await input.isVisible())) {
|
if (!(await pickerInputVisible())) {
|
||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectorySelect.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
}
|
}
|
||||||
await expect(input).toBeVisible();
|
await expect
|
||||||
|
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||||
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||||
await input.fill(trimmedDirectory);
|
const activeInput =
|
||||||
await input.press('Enter');
|
(await directorySearchInput.isVisible().catch(() => false))
|
||||||
|
? directorySearchInput
|
||||||
|
: legacyInput;
|
||||||
|
|
||||||
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
|
await activeInput.fill(trimmedDirectory);
|
||||||
const useOption = page.getByTestId('working-directory-custom-option').first();
|
|
||||||
if (await useOption.isVisible().catch(() => false)) {
|
if (activeInput === directorySearchInput) {
|
||||||
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
|
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||||
await useOption.click({ force: true });
|
const plainOption = page
|
||||||
|
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||||
|
.first();
|
||||||
|
const prefixedUseOption = page
|
||||||
|
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (await plainOption.isVisible().catch(() => false)) {
|
||||||
|
await plainOption.click({ force: true });
|
||||||
|
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
|
||||||
|
await prefixedUseOption.click({ force: true });
|
||||||
|
} else {
|
||||||
|
// Fallback: accept highlighted option (directory suggestion).
|
||||||
|
await activeInput.press('Enter');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy path picker fallback.
|
||||||
|
await activeInput.press('Enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the sheet to close after clicking "Use"
|
// Wait for picker to close.
|
||||||
await expect(input).not.toBeVisible({ timeout: 10000 });
|
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||||
if (trimmedDirectory.startsWith('/var/')) {
|
if (trimmedDirectory.startsWith('/var/')) {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { gotoHome } from "./helpers/app";
|
||||||
|
|
||||||
|
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||||
|
await gotoHome(page);
|
||||||
|
|
||||||
|
const trigger = page.getByText("Project", { exact: true }).first();
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||||
|
targetFound: boolean;
|
||||||
|
visibleAtOrigin: boolean;
|
||||||
|
records: Array<{ left: number; top: number; opacity: number }>;
|
||||||
|
}>((resolve) => {
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||||
|
|
||||||
|
const capture = () => {
|
||||||
|
if (!target) return;
|
||||||
|
const style = getComputedStyle(target);
|
||||||
|
records.push({
|
||||||
|
left: Number.parseFloat(style.left || "0"),
|
||||||
|
top: Number.parseFloat(style.top || "0"),
|
||||||
|
opacity: Number.parseFloat(style.opacity || "1"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||||
|
let current: HTMLElement | null = node;
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
const style = getComputedStyle(current);
|
||||||
|
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryResolveTarget = (root: HTMLElement) => {
|
||||||
|
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||||
|
for (const element of stack) {
|
||||||
|
if (!element.textContent?.includes("No projects")) continue;
|
||||||
|
const container = getContainer(element);
|
||||||
|
if (!container) continue;
|
||||||
|
target = container;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
observer.disconnect();
|
||||||
|
if (!target) {
|
||||||
|
resolve({
|
||||||
|
targetFound: false,
|
||||||
|
visibleAtOrigin: false,
|
||||||
|
records: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleAtOrigin = records.some(
|
||||||
|
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
targetFound: true,
|
||||||
|
visibleAtOrigin,
|
||||||
|
records,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleFrames = () => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const added of Array.from(mutation.addedNodes)) {
|
||||||
|
if (!(added instanceof HTMLElement)) continue;
|
||||||
|
if (tryResolveTarget(added)) {
|
||||||
|
sampleFrames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mutation.type === "attributes" &&
|
||||||
|
mutation.target instanceof HTMLElement &&
|
||||||
|
!target &&
|
||||||
|
tryResolveTarget(mutation.target)
|
||||||
|
) {
|
||||||
|
sampleFrames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["style"],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => finish(), 2500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||||
|
|
||||||
|
expect(probe.targetFound).toBe(true);
|
||||||
|
expect(probe.visibleAtOrigin).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoHome } from "./helpers/app";
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||||
|
await gotoHome(page);
|
||||||
|
|
||||||
|
const workingDirectorySelect = page
|
||||||
|
.locator('[data-testid="working-directory-select"]:visible')
|
||||||
|
.first();
|
||||||
|
await expect(workingDirectorySelect).toBeVisible();
|
||||||
|
await workingDirectorySelect.click({ force: true });
|
||||||
|
|
||||||
|
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||||
|
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||||
|
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||||
|
if (!(trigger instanceof HTMLElement)) {
|
||||||
|
throw new Error("Missing working-directory-select trigger.");
|
||||||
|
}
|
||||||
|
if (!(searchInput instanceof HTMLInputElement)) {
|
||||||
|
throw new Error("Missing working directory search input.");
|
||||||
|
}
|
||||||
|
if (!(container instanceof HTMLElement)) {
|
||||||
|
throw new Error("Missing combobox desktop container.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
samples: 0,
|
||||||
|
underTriggerSamples: 0,
|
||||||
|
emptyWhileSearchingSamples: 0,
|
||||||
|
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||||
|
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||||
|
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||||
|
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||||
|
logs: [] as Array<{
|
||||||
|
reason: string;
|
||||||
|
query: string;
|
||||||
|
searchDelta: number;
|
||||||
|
containerDelta: number;
|
||||||
|
hasEmpty: boolean;
|
||||||
|
containerTop: number;
|
||||||
|
containerBottom: number;
|
||||||
|
triggerTop: number;
|
||||||
|
searchBottom: number;
|
||||||
|
}>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sample = (reason: string) => {
|
||||||
|
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const searchRect = searchInput.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||||
|
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||||
|
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||||
|
|
||||||
|
state.samples += 1;
|
||||||
|
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||||
|
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||||
|
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||||
|
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||||
|
if (searchDelta > 2 || containerDelta > 2) {
|
||||||
|
state.underTriggerSamples += 1;
|
||||||
|
}
|
||||||
|
if (hasEmpty) {
|
||||||
|
state.emptyWhileSearchingSamples += 1;
|
||||||
|
}
|
||||||
|
if (state.logs.length < 80) {
|
||||||
|
state.logs.push({
|
||||||
|
reason,
|
||||||
|
query,
|
||||||
|
searchDelta,
|
||||||
|
containerDelta,
|
||||||
|
hasEmpty,
|
||||||
|
containerTop: containerRect.top,
|
||||||
|
containerBottom: containerRect.bottom,
|
||||||
|
triggerTop: triggerRect.top,
|
||||||
|
searchBottom: searchRect.bottom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||||
|
mutationObserver.observe(container, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
resizeObserver.observe(searchInput);
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
const loop = () => {
|
||||||
|
sample("raf");
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
(window as any).__paseoComboboxObserver = {
|
||||||
|
stop: () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
sample("stop");
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||||
|
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||||
|
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||||
|
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
"/tmp/paseo-stability-a",
|
||||||
|
"/tmp/paseo-stability-ab",
|
||||||
|
"/tmp/paseo-stability-abc",
|
||||||
|
"/tmp/paseo-stability-longer-branch",
|
||||||
|
"/tmp/paseo-stability-z",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
await searchInput.fill("");
|
||||||
|
await searchInput.type(query, { delay: 20 });
|
||||||
|
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||||
|
await expect(customOption).toBeVisible();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||||
|
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||||
|
|
||||||
|
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||||
|
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||||
|
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||||
|
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||||
|
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||||
|
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 14.0.0"
|
"version": ">= 14.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
"channel": "production",
|
"channel": "production",
|
||||||
"env": {
|
"env": {
|
||||||
"APP_VARIANT": "production"
|
"APP_VARIANT": "production"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"autoIncrement": "versionCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
@@ -33,6 +37,9 @@
|
|||||||
"production": {
|
"production": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"ascAppId": "6758887924"
|
"ascAppId": "6758887924"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"releaseStatus": "draft"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/server": "0.1.4",
|
"@getpaseo/server": "0.1.13",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -883,11 +883,12 @@ export function WorkingDirectoryDropdown({
|
|||||||
options={options}
|
options={options}
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
onSelect={onSelectPath}
|
onSelect={onSelectPath}
|
||||||
searchPlaceholder="/path/to/project"
|
searchPlaceholder="Search directories..."
|
||||||
emptyText={emptyText}
|
emptyText={emptyText}
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
customValuePrefix="Use"
|
customValuePrefix=""
|
||||||
customValueDescription="Launch the agent in this directory"
|
customValueKind="directory"
|
||||||
|
optionsPosition="above-search"
|
||||||
title="Working directory"
|
title="Working directory"
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ import {
|
|||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
import { CommandAutocomplete } from "./command-autocomplete";
|
import { CommandAutocomplete } from "./command-autocomplete";
|
||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import {
|
||||||
|
useAgentCommandsQuery,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
} from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
@@ -55,6 +58,8 @@ interface AgentInputAreaProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
@@ -69,6 +74,7 @@ export function AgentInputArea({
|
|||||||
onChangeText,
|
onChangeText,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
|
commandDraftConfig,
|
||||||
}: AgentInputAreaProps) {
|
}: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -117,13 +123,39 @@ export function AgentInputArea({
|
|||||||
// Command autocomplete logic
|
// Command autocomplete logic
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||||
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||||
|
const normalizedCommandDraftConfig = useMemo(() => {
|
||||||
|
if (!commandDraftConfig) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Prefetch commands when agent is available (not on new agent screen)
|
const cwd = commandDraftConfig.cwd.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeId = commandDraftConfig.modeId?.trim() ?? "";
|
||||||
|
const model = commandDraftConfig.model?.trim() ?? "";
|
||||||
|
const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? "";
|
||||||
|
return {
|
||||||
|
provider: commandDraftConfig.provider,
|
||||||
|
cwd,
|
||||||
|
...(modeId ? { modeId } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
|
};
|
||||||
|
}, [commandDraftConfig]);
|
||||||
|
|
||||||
|
// Prefetch commands for real agents and for draft screens when context is available.
|
||||||
const isRealAgent = agentId && !agentId.startsWith("__");
|
const isRealAgent = agentId && !agentId.startsWith("__");
|
||||||
|
const commandQueryDraftConfig = isRealAgent
|
||||||
|
? undefined
|
||||||
|
: normalizedCommandDraftConfig;
|
||||||
|
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
|
||||||
const { commands } = useAgentCommandsQuery({
|
const { commands } = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: !!isRealAgent && !!serverId,
|
enabled: canLoadCommands,
|
||||||
|
draftConfig: commandQueryDraftConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter commands for keyboard navigation
|
// Filter commands for keyboard navigation
|
||||||
@@ -234,15 +266,38 @@ export function AgentInputArea({
|
|||||||
}, [onSubmitMessage]);
|
}, [onSubmitMessage]);
|
||||||
|
|
||||||
const isAgentRunning = agent?.status === "running";
|
const isAgentRunning = agent?.status === "running";
|
||||||
|
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
||||||
|
|
||||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
||||||
|
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
||||||
|
if (agentUpdatedAtMs < previousUpdatedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const wasRunning = prevIsAgentRunningRef.current;
|
const wasRunning = prevIsAgentRunningRef.current;
|
||||||
|
let shouldClearProcessing = false;
|
||||||
|
|
||||||
|
if (isProcessing) {
|
||||||
|
const hasEnteredRunning = !wasRunning && isAgentRunning;
|
||||||
|
const hasFreshRunningUpdateWhileRunning =
|
||||||
|
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
|
||||||
|
const hasStoppedRunning = wasRunning && !isAgentRunning;
|
||||||
|
|
||||||
|
shouldClearProcessing =
|
||||||
|
hasEnteredRunning ||
|
||||||
|
hasFreshRunningUpdateWhileRunning ||
|
||||||
|
hasStoppedRunning;
|
||||||
|
}
|
||||||
|
|
||||||
prevIsAgentRunningRef.current = isAgentRunning;
|
prevIsAgentRunningRef.current = isAgentRunning;
|
||||||
if (!wasRunning && isAgentRunning && isProcessing) {
|
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
||||||
|
|
||||||
|
if (shouldClearProcessing) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [isAgentRunning, isProcessing]);
|
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const updateQueue = useCallback(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||||
@@ -612,7 +667,7 @@ export function AgentInputArea({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleCancelAgent}
|
onPress={handleCancelAgent}
|
||||||
@@ -720,13 +775,14 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command autocomplete dropdown */}
|
{/* Command autocomplete dropdown */}
|
||||||
{showCommandAutocomplete && isRealAgent && (
|
{showCommandAutocomplete && canLoadCommands && (
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
filter={commandFilter}
|
filter={commandFilter}
|
||||||
selectedIndex={commandSelectedIndex}
|
selectedIndex={commandSelectedIndex}
|
||||||
onSelect={handleCommandSelect}
|
onSelect={handleCommandSelect}
|
||||||
|
draftConfig={commandQueryDraftConfig}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -756,6 +812,7 @@ export function AgentInputArea({
|
|||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -105,12 +105,6 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear attention flag when opening agent
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
|
||||||
if (session?.client) {
|
|
||||||
session.client.clearAgentAttention(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||||
startNavigationTiming(navigationKey, {
|
startNavigationTiming(navigationKey, {
|
||||||
from: "home",
|
from: "home",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import {
|
||||||
|
useAgentCommandsQuery,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
} from "@/hooks/use-agent-commands-query";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
@@ -16,6 +19,7 @@ interface CommandAutocompleteProps {
|
|||||||
filter: string;
|
filter: string;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (command: AgentSlashCommand) => void;
|
onSelect: (command: AgentSlashCommand) => void;
|
||||||
|
draftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandAutocomplete({
|
export function CommandAutocomplete({
|
||||||
@@ -24,12 +28,14 @@ export function CommandAutocomplete({
|
|||||||
filter,
|
filter,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
draftConfig,
|
||||||
}: CommandAutocompleteProps) {
|
}: CommandAutocompleteProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
draftConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter commands based on input after /
|
// Filter commands based on input after /
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
...(Platform.OS === "web"
|
||||||
|
? {
|
||||||
|
whiteSpace: "pre",
|
||||||
|
overflowWrap: "normal",
|
||||||
|
}
|
||||||
|
: null),
|
||||||
},
|
},
|
||||||
headerLine: {
|
headerLine: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
|
|||||||
@@ -240,8 +240,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
|||||||
width: resizeWidth.value,
|
width: resizeWidth.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mobile: full-screen overlay with gesture
|
// Mobile: full-screen overlay with gesture.
|
||||||
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||||
|
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -52,6 +51,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Git Actions Data Structure
|
// Git Actions Data Structure
|
||||||
@@ -85,11 +85,7 @@ interface GitActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openURLInNewTab(url: string): void {
|
function openURLInNewTab(url: string): void {
|
||||||
if (Platform.OS === "web") {
|
void openExternalUrl(url);
|
||||||
window.open(url, "_blank", "noopener");
|
|
||||||
} else {
|
|
||||||
void Linking.openURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ interface BackHeaderProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
export function BackHeader({
|
||||||
|
title,
|
||||||
|
rightContent,
|
||||||
|
onBack,
|
||||||
|
}: BackHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Text } from "react-native";
|
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Menu, PanelLeft } from "lucide-react-native";
|
import { PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -12,7 +12,39 @@ interface MenuHeaderProps {
|
|||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
interface SidebarMenuToggleProps {
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||||
|
testID?: string;
|
||||||
|
nativeID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_MENU_LINE_WIDTH = 16;
|
||||||
|
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
||||||
|
const MOBILE_MENU_LINE_HEIGHT = 2;
|
||||||
|
|
||||||
|
function MobileMenuIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.mobileMenuIcon} pointerEvents="none">
|
||||||
|
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||||
|
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.mobileMenuLine,
|
||||||
|
styles.mobileMenuLineShort,
|
||||||
|
{ backgroundColor: color },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarMenuToggle({
|
||||||
|
style,
|
||||||
|
tooltipSide = "right",
|
||||||
|
testID = "menu-button",
|
||||||
|
nativeID = "menu-button",
|
||||||
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
@@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
|
||||||
const menuIconColor = !isMobile && isOpen
|
const menuIconColor = !isMobile && isOpen
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted;
|
: theme.colors.foregroundMuted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderToggleButton
|
||||||
|
onPress={toggleAgentList}
|
||||||
|
tooltipLabel="Toggle sidebar"
|
||||||
|
tooltipKeys={toggleShortcutKeys}
|
||||||
|
tooltipSide={tooltipSide}
|
||||||
|
testID={testID}
|
||||||
|
nativeID={nativeID}
|
||||||
|
style={style}
|
||||||
|
accessible
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||||
|
accessibilityState={{ expanded: isOpen }}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileMenuIcon color={menuIconColor} />
|
||||||
|
) : (
|
||||||
|
<PanelLeft size={16} color={menuIconColor} />
|
||||||
|
)}
|
||||||
|
</HeaderToggleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuHeader({
|
||||||
|
title,
|
||||||
|
rightContent,
|
||||||
|
}: MenuHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<HeaderToggleButton
|
<SidebarMenuToggle />
|
||||||
onPress={toggleAgentList}
|
|
||||||
tooltipLabel="Toggle sidebar"
|
|
||||||
tooltipKeys={toggleShortcutKeys}
|
|
||||||
tooltipSide="right"
|
|
||||||
testID="menu-button"
|
|
||||||
nativeID="menu-button"
|
|
||||||
accessible
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
|
||||||
accessibilityState={{ expanded: isOpen }}
|
|
||||||
>
|
|
||||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
|
||||||
</HeaderToggleButton>
|
|
||||||
{title && (
|
{title && (
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
@@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
|
mobileMenuIcon: {
|
||||||
|
width: MOBILE_MENU_LINE_WIDTH,
|
||||||
|
height: 12,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
mobileMenuLine: {
|
||||||
|
width: MOBILE_MENU_LINE_WIDTH,
|
||||||
|
height: MOBILE_MENU_LINE_HEIGHT,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
mobileMenuLineShort: {
|
||||||
|
width: MOBILE_MENU_LINE_SHORT_WIDTH,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
|
|||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* and safe-area logic in one place.
|
||||||
*/
|
*/
|
||||||
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
|
export function ScreenHeader({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
leftStyle,
|
||||||
|
rightStyle,
|
||||||
|
}: ScreenHeaderProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface MessageInputProps {
|
|||||||
isAgentRunning?: boolean;
|
isAgentRunning?: boolean;
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
|
/** Optional handler used when submit button is in loading state. */
|
||||||
|
onSubmitLoadingPress?: () => void;
|
||||||
/** Intercept key press events before default handling. Return true to prevent default. */
|
/** Intercept key press events before default handling. Return true to prevent default. */
|
||||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
onQueue,
|
onQueue,
|
||||||
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -576,6 +579,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
|
const canPressLoadingButton =
|
||||||
|
isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
|
const isSendButtonDisabled =
|
||||||
|
disabled ||
|
||||||
|
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
|
const submitAccessibilityLabel = canPressLoadingButton
|
||||||
|
? "Interrupt agent"
|
||||||
|
: isAgentRunning
|
||||||
|
? "Send and interrupt"
|
||||||
|
: "Send message";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="message-input-root">
|
<View style={styles.container} testID="message-input-root">
|
||||||
@@ -724,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{shouldShowSendButton && isAgentRunning && onQueue && (
|
{hasSendableContent && isAgentRunning && onQueue && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleQueueMessage}
|
onPress={handleQueueMessage}
|
||||||
@@ -748,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
)}
|
)}
|
||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleSendMessage}
|
onPress={
|
||||||
disabled={
|
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
|
||||||
isSubmitDisabled ||
|
|
||||||
isSubmitLoading ||
|
|
||||||
disabled
|
|
||||||
}
|
}
|
||||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
disabled={isSendButtonDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityLabel={submitAccessibilityLabel}
|
||||||
style={[
|
accessibilityRole="button"
|
||||||
styles.sendButton,
|
style={[
|
||||||
(isSubmitDisabled ||
|
styles.sendButton,
|
||||||
isSubmitLoading ||
|
isSendButtonDisabled && styles.buttonDisabled,
|
||||||
disabled) &&
|
]}
|
||||||
styles.buttonDisabled,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{isSubmitLoading ? (
|
{isSubmitLoading ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } from "react";
|
import type { ReactNode, ComponentType } from "react";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import MaskedView from "@react-native-masked-view/masked-view";
|
import MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
@@ -72,6 +71,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
|||||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
@@ -119,6 +119,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
let webToolCallShimmerRegistered = false;
|
let webToolCallShimmerRegistered = false;
|
||||||
|
const SCROLL_EDGE_EPSILON = 0.5;
|
||||||
|
type ScrollAxis = "x" | "y";
|
||||||
|
|
||||||
function ensureWebToolCallShimmerKeyframes() {
|
function ensureWebToolCallShimmerKeyframes() {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
@@ -145,6 +147,113 @@ function ensureWebToolCallShimmerKeyframes() {
|
|||||||
webToolCallShimmerRegistered = true;
|
webToolCallShimmerRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWheelEventElementTarget(
|
||||||
|
event: WheelEvent,
|
||||||
|
fallback: HTMLElement
|
||||||
|
): HTMLElement {
|
||||||
|
const { target } = event;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
if (target instanceof Node && target.parentElement) {
|
||||||
|
return target.parentElement;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canElementScrollInDirection(
|
||||||
|
element: HTMLElement,
|
||||||
|
axis: ScrollAxis,
|
||||||
|
delta: number
|
||||||
|
): boolean {
|
||||||
|
if (delta === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||||||
|
const isScrollableOverflow =
|
||||||
|
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||||||
|
if (!isScrollableOverflow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||||||
|
const scrollSize =
|
||||||
|
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
|
||||||
|
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||||||
|
}
|
||||||
|
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canScrollInsideDetailFromTarget(
|
||||||
|
detailRoot: HTMLElement,
|
||||||
|
startElement: HTMLElement,
|
||||||
|
axis: ScrollAxis,
|
||||||
|
delta: number
|
||||||
|
): boolean {
|
||||||
|
if (delta === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: HTMLElement | null = startElement;
|
||||||
|
while (current) {
|
||||||
|
if (canElementScrollInDirection(current, axis, delta)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current === detailRoot) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldStopDetailWheelPropagation(
|
||||||
|
detailRoot: HTMLElement,
|
||||||
|
event: WheelEvent
|
||||||
|
): boolean {
|
||||||
|
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||||||
|
const verticalDelta = event.deltaY;
|
||||||
|
const horizontalDelta =
|
||||||
|
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
|
||||||
|
|
||||||
|
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||||||
|
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||||||
|
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canScrollVertically = hasVerticalIntent
|
||||||
|
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||||||
|
: false;
|
||||||
|
const canScrollHorizontally = hasHorizontalIntent
|
||||||
|
? canScrollInsideDetailFromTarget(
|
||||||
|
detailRoot,
|
||||||
|
startElement,
|
||||||
|
"x",
|
||||||
|
horizontalDelta
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (hasVerticalIntent && hasHorizontalIntent) {
|
||||||
|
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||||||
|
return isVerticalDominant
|
||||||
|
? canScrollVertically || canScrollHorizontally
|
||||||
|
: canScrollHorizontally || canScrollVertically;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasVerticalIntent) {
|
||||||
|
return canScrollVertically;
|
||||||
|
}
|
||||||
|
return canScrollHorizontally;
|
||||||
|
}
|
||||||
|
|
||||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -587,12 +696,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLinkPress = useCallback((url: string) => {
|
const handleLinkPress = useCallback((url: string) => {
|
||||||
if (Platform.OS === "web") {
|
void openExternalUrl(url);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
// react-native-markdown-display opens the link itself when this returns true.
|
||||||
} else {
|
// We already handled it above, so return false to avoid duplicate opens.
|
||||||
void Linking.openURL(url);
|
return false;
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo(() => {
|
||||||
@@ -1170,6 +1277,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const hasDetailContent = Boolean(renderDetails);
|
const hasDetailContent = Boolean(renderDetails);
|
||||||
const detailContent =
|
const detailContent =
|
||||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||||
|
const detailWrapperRef = useRef<View | null>(null);
|
||||||
|
|
||||||
const nativeGradientIdRef = useRef(
|
const nativeGradientIdRef = useRef(
|
||||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||||
@@ -1289,6 +1397,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
shimmerTranslateX,
|
shimmerTranslateX,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||||
|
if (!node || typeof node.addEventListener !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopWheelPropagation = (event: WheelEvent) => {
|
||||||
|
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener("wheel", stopWheelPropagation);
|
||||||
|
};
|
||||||
|
}, [isExpanded, hasDetailContent]);
|
||||||
|
|
||||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateX: shimmerTranslateX.value }],
|
transform: [{ translateX: shimmerTranslateX.value }],
|
||||||
}));
|
}));
|
||||||
@@ -1525,6 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{detailContent ? (
|
{detailContent ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
ref={detailWrapperRef}
|
||||||
style={expandableBadgeStylesheet.detailWrapper}
|
style={expandableBadgeStylesheet.detailWrapper}
|
||||||
onHoverIn={() => onDetailHoverChange?.(true)}
|
onHoverIn={() => onDetailHoverChange?.(true)}
|
||||||
onHoverOut={() => onDetailHoverChange?.(false)}
|
onHoverOut={() => onDetailHoverChange?.(false)}
|
||||||
|
|||||||
@@ -592,11 +592,6 @@ export function SidebarAgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = useSessionStore.getState().sessions[entry.agent.serverId];
|
|
||||||
if (session?.client) {
|
|
||||||
session.client.clearAgentAttention(entry.agent.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
|
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
|
||||||
startNavigationTiming(navigationKey, {
|
startNavigationTiming(navigationKey, {
|
||||||
from: "home",
|
from: "home",
|
||||||
@@ -1032,17 +1027,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
agentTitle: {
|
agentTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 0.85,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
agentTitleInactive: {
|
agentTitleInactive: {
|
||||||
color: theme.colors.foregroundMuted,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
agentTitleSelected: {
|
agentTitleSelected: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
relativeTimeText: {
|
relativeTimeText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
opacity: 0.78,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
shortcutBadge: {
|
shortcutBadge: {
|
||||||
@@ -1084,10 +1081,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
agentMetaProjectName: {
|
agentMetaProjectName: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
|
opacity: 0.78,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 1,
|
flexShrink: 0,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
agentMetaBranchBadge: {
|
agentMetaBranchBadge: {
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
@@ -1095,14 +1092,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: 1,
|
paddingVertical: 1,
|
||||||
flexShrink: 0,
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
agentMetaBranchBadgeSelected: {
|
agentMetaBranchBadgeSelected: {
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
agentMetaBranchText: {
|
agentMetaBranchText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
|
opacity: 0.76,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
selectionBar: {
|
selectionBar: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
|||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const dragHandlers = useTauriDragHandlers();
|
||||||
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
|
|
||||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
@@ -324,8 +325,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
|
|
||||||
|
|
||||||
// Render mobile sidebar
|
// Render mobile sidebar
|
||||||
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
|
// On web, keep the overlay interactive only while the sidebar is open.
|
||||||
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
|
// This preserves swipe/scroll behavior without blocking taps when closed.
|
||||||
|
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
@@ -465,7 +467,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View
|
||||||
|
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||||
|
{...dragHandlers}
|
||||||
|
>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.newAgentButton}
|
style={styles.newAgentButton}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ interface TerminalEmulatorProps {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
|
resizeRequestToken?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -47,8 +49,10 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onOutputChunkConsumed,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
|
resizeRequestToken = 0,
|
||||||
}: TerminalEmulatorProps) {
|
}: TerminalEmulatorProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -108,19 +112,30 @@ export default function TerminalEmulator({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = runtimeRef.current;
|
const runtime = runtimeRef.current;
|
||||||
if (!runtime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputChunkSequence <= 0) {
|
if (outputChunkSequence <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outputChunkText.length === 0) {
|
|
||||||
runtime.clear();
|
if (!runtime) {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.write({ text: outputChunkText });
|
|
||||||
}, [outputChunkSequence, outputChunkText]);
|
if (outputChunkText.length === 0) {
|
||||||
|
runtime.clear({
|
||||||
|
onCommitted: () => {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runtime.write({
|
||||||
|
text: outputChunkText,
|
||||||
|
onCommitted: () => {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
@@ -129,6 +144,13 @@ export default function TerminalEmulator({
|
|||||||
runtimeRef.current?.focus();
|
runtimeRef.current?.focus();
|
||||||
}, [focusRequestToken]);
|
}, [focusRequestToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizeRequestToken <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runtimeRef.current?.resize({ force: true });
|
||||||
|
}, [resizeRequestToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -15,16 +16,19 @@ import Svg, {
|
|||||||
Stop,
|
Stop,
|
||||||
} from "react-native-svg";
|
} from "react-native-svg";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
resolvePendingModifierDataInput,
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import {
|
import {
|
||||||
TerminalOutputPump,
|
TerminalOutputPump,
|
||||||
type TerminalOutputChunk,
|
type TerminalOutputChunk,
|
||||||
} from "@/terminal/runtime/terminal-output-pump";
|
} from "@/terminal/runtime/terminal-output-pump";
|
||||||
|
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -42,6 +46,7 @@ interface TerminalPaneProps {
|
|||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
const MAX_OUTPUT_CHARS = 200_000;
|
||||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||||
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
ctrl: "Ctrl",
|
ctrl: "Ctrl",
|
||||||
@@ -72,6 +77,24 @@ type TerminalOutputChunkState = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingTerminalInput =
|
||||||
|
| {
|
||||||
|
type: "data";
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "key";
|
||||||
|
input: {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
const EMPTY_MODIFIERS: ModifierState = {
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -117,10 +140,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
|
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||||
|
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
||||||
|
|
||||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||||
@@ -136,22 +161,40 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [selectedTerminalId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const outputDeliveryQueue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
setSelectedOutputChunk(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outputDeliveryQueueRef.current = outputDeliveryQueue;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (outputDeliveryQueueRef.current === outputDeliveryQueue) {
|
||||||
|
outputDeliveryQueueRef.current = null;
|
||||||
|
}
|
||||||
|
outputDeliveryQueue.reset();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const outputPump = new TerminalOutputPump({
|
const outputPump = new TerminalOutputPump({
|
||||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
||||||
setSelectedOutputChunk(chunk);
|
outputDeliveryQueueRef.current?.enqueue(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
outputPumpRef.current = outputPump;
|
outputPumpRef.current = outputPump;
|
||||||
@@ -210,8 +253,36 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return () => clearHoverOutTimeout();
|
return () => clearHoverOutTimeout();
|
||||||
}, [clearHoverOutTimeout]);
|
}, [clearHoverOutTimeout]);
|
||||||
|
|
||||||
|
const requestTerminalFocus = useCallback(() => {
|
||||||
|
setFocusRequestToken((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
const requestTerminalReflow = useCallback(() => {
|
||||||
|
setResizeRequestToken((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!selectedTerminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Navigation transitions can temporarily report stale dimensions.
|
||||||
|
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
||||||
|
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
requestTerminalReflow();
|
||||||
|
}, delayMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const handle of timeoutHandles) {
|
||||||
|
clearTimeout(handle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [requestTerminalReflow, selectedTerminalId])
|
||||||
|
);
|
||||||
|
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", serverId, cwd] as const,
|
queryKey: terminalsQueryKey,
|
||||||
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -246,14 +317,43 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
void queryClient.refetchQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !cwd.startsWith("/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = client.on("terminals_changed", (message) => {
|
||||||
|
if (message.type !== "terminals_changed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.payload.cwd !== cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: terminalsQueryKey,
|
||||||
|
});
|
||||||
|
void queryClient.refetchQueries({
|
||||||
|
queryKey: terminalsQueryKey,
|
||||||
|
type: "active",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.subscribeTerminals({ cwd });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
client.unsubscribeTerminals({ cwd });
|
||||||
|
};
|
||||||
|
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
|
||||||
|
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -263,12 +363,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return await client.createTerminal(cwd);
|
return await client.createTerminal(cwd);
|
||||||
},
|
},
|
||||||
onSuccess: (payload) => {
|
onSuccess: (payload) => {
|
||||||
if (payload.terminal) {
|
const createdTerminal = payload.terminal;
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
|
if (createdTerminal) {
|
||||||
setSelectedTerminalId(payload.terminal.id);
|
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||||
|
const nextTerminals = upsertTerminalListEntry({
|
||||||
|
terminals: current?.terminals ?? [],
|
||||||
|
terminal: createdTerminal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd: current?.cwd ?? cwd,
|
||||||
|
terminals: nextTerminals,
|
||||||
|
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
||||||
|
setSelectedTerminalId(createdTerminal.id);
|
||||||
|
requestTerminalFocus();
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -294,10 +408,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
void queryClient.refetchQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -405,6 +519,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}, [client, handleStreamControllerStatus, isConnected]);
|
}, [client, handleStreamControllerStatus, isConnected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
outputDeliveryQueueRef.current?.reset();
|
||||||
|
pendingTerminalInputRef.current = [];
|
||||||
|
setSelectedOutputChunk({ sequence: 0, text: "" });
|
||||||
outputPumpRef.current?.setSelectedTerminal({
|
outputPumpRef.current?.setSelectedTerminal({
|
||||||
terminalId: selectedTerminalId,
|
terminalId: selectedTerminalId,
|
||||||
});
|
});
|
||||||
@@ -422,6 +539,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId
|
||||||
? activeStream.streamId
|
? activeStream.streamId
|
||||||
: null;
|
: null;
|
||||||
|
const getCurrentActiveStreamId = useCallback(() => {
|
||||||
|
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedTerminal = useMemo(
|
const selectedTerminal = useMemo(
|
||||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||||
@@ -431,6 +551,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
}, [createTerminalMutation]);
|
}, [createTerminalMutation]);
|
||||||
|
|
||||||
|
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
|
const queue = pendingTerminalInputRef.current;
|
||||||
|
queue.push(entry);
|
||||||
|
if (queue.length > 512) {
|
||||||
|
queue.splice(0, queue.length - 512);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingTerminalInput = useCallback(() => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
|
if (currentStreamId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = pendingTerminalInputRef.current;
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = queue.splice(0, queue.length);
|
||||||
|
|
||||||
|
for (const entry of pending) {
|
||||||
|
if (entry.type === "data") {
|
||||||
|
client.sendTerminalStreamInput(currentStreamId, entry.data);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.sendTerminalStreamKey(currentStreamId, entry.input);
|
||||||
|
}
|
||||||
|
}, [client, getCurrentActiveStreamId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
flushPendingTerminalInput();
|
||||||
|
}, [activeStreamId, flushPendingTerminalInput]);
|
||||||
|
|
||||||
const handleCloseTerminal = useCallback(
|
const handleCloseTerminal = useCallback(
|
||||||
(terminalId: string) => {
|
(terminalId: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -444,10 +600,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
[killTerminalMutation]
|
[killTerminalMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
|
||||||
setFocusRequestToken((current) => current + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearPendingModifiers = useCallback(() => {
|
const clearPendingModifiers = useCallback(() => {
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -462,19 +614,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
}
|
}
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!client || activeStreamId === null) {
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
terminalDebugLog({
|
if (!client || currentStreamId === null) {
|
||||||
scope: "terminal-pane",
|
enqueuePendingTerminalInput({
|
||||||
event: "input:key:drop-no-stream",
|
type: "key",
|
||||||
details: {
|
input: {
|
||||||
key: input.key,
|
key: normalizeTerminalTransportKey(input.key),
|
||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
alt: input.alt,
|
alt: input.alt,
|
||||||
activeStreamId,
|
meta: input.meta,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
||||||
@@ -486,10 +638,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
alt: input.alt,
|
alt: input.alt,
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.sendTerminalStreamKey(activeStreamId, {
|
client.sendTerminalStreamKey(currentStreamId, {
|
||||||
key: normalizedKey,
|
key: normalizedKey,
|
||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
@@ -498,7 +650,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[client, activeStreamId]
|
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalData = useCallback(
|
const handleTerminalData = useCallback(
|
||||||
@@ -506,13 +658,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "terminal-pane",
|
scope: "terminal-pane",
|
||||||
event: "input:data:received",
|
event: "input:data:received",
|
||||||
details: {
|
details: {
|
||||||
length: data.length,
|
length: data.length,
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,14 +694,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client || activeStreamId === null) {
|
if (!client || currentStreamId === null) {
|
||||||
terminalDebugLog({
|
enqueuePendingTerminalInput({
|
||||||
scope: "terminal-pane",
|
type: "data",
|
||||||
event: "input:data:drop-no-stream",
|
data,
|
||||||
details: {
|
|
||||||
length: data.length,
|
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -558,19 +707,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
details: {
|
details: {
|
||||||
length: data.length,
|
length: data.length,
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.sendTerminalStreamInput(activeStreamId, data);
|
client.sendTerminalStreamInput(currentStreamId, data);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeStreamId,
|
|
||||||
clearPendingModifiers,
|
clearPendingModifiers,
|
||||||
client,
|
client,
|
||||||
|
getCurrentActiveStreamId,
|
||||||
modifiers.alt,
|
modifiers.alt,
|
||||||
modifiers.ctrl,
|
modifiers.ctrl,
|
||||||
modifiers.shift,
|
modifiers.shift,
|
||||||
sendTerminalKey,
|
sendTerminalKey,
|
||||||
|
enqueuePendingTerminalInput,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -626,6 +776,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
|
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
||||||
|
outputDeliveryQueueRef.current?.consume({ sequence });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleModifier = useCallback(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||||
@@ -808,8 +962,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
|
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||||
pendingModifiers={modifiers}
|
pendingModifiers={modifiers}
|
||||||
focusRequestToken={focusRequestToken}
|
focusRequestToken={focusRequestToken}
|
||||||
|
resizeRequestToken={resizeRequestToken}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -819,7 +975,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching ? (
|
{isAttaching ? (
|
||||||
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
<View
|
||||||
|
style={styles.attachOverlay}
|
||||||
|
pointerEvents="none"
|
||||||
|
testID="terminal-attach-loading"
|
||||||
|
>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
} else if (detail?.type === "worktree_setup") {
|
||||||
|
const setupLog = detail.log.replace(/^\n+/, "");
|
||||||
|
const hasLog = setupLog.length > 0;
|
||||||
|
sections.push(
|
||||||
|
<View
|
||||||
|
key="worktree-setup"
|
||||||
|
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||||
|
>
|
||||||
|
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
||||||
|
<ScrollView
|
||||||
|
style={[
|
||||||
|
styles.codeVerticalScroll,
|
||||||
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
|
shouldFill && styles.fillHeight,
|
||||||
|
]}
|
||||||
|
contentContainerStyle={styles.codeVerticalContent}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
contentContainerStyle={styles.codeHorizontalContent}
|
||||||
|
>
|
||||||
|
<View style={styles.codeLine}>
|
||||||
|
<Text selectable style={styles.scrollText}>
|
||||||
|
{hasLog
|
||||||
|
? setupLog
|
||||||
|
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
} else if (detail?.type === "edit") {
|
} else if (detail?.type === "edit") {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View
|
<View
|
||||||
@@ -356,6 +393,12 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
|
...(Platform.OS === "web"
|
||||||
|
? {
|
||||||
|
whiteSpace: "pre",
|
||||||
|
overflowWrap: "normal",
|
||||||
|
}
|
||||||
|
: null),
|
||||||
},
|
},
|
||||||
shellPrompt: {
|
shellPrompt: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildVisibleComboboxOptions,
|
||||||
|
getComboboxFallbackIndex,
|
||||||
|
orderVisibleComboboxOptions,
|
||||||
|
} from "./combobox-options";
|
||||||
|
|
||||||
|
describe("buildVisibleComboboxOptions", () => {
|
||||||
|
const options = [
|
||||||
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
||||||
|
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps a custom row visible while searching with no matches", () => {
|
||||||
|
const visible = buildVisibleComboboxOptions({
|
||||||
|
options,
|
||||||
|
searchQuery: "/tmp/new-project",
|
||||||
|
searchable: true,
|
||||||
|
allowCustomValue: true,
|
||||||
|
customValuePrefix: "",
|
||||||
|
customValueKind: "directory",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visible).toHaveLength(1);
|
||||||
|
expect(visible[0]).toEqual({
|
||||||
|
id: "/tmp/new-project",
|
||||||
|
label: "/tmp/new-project",
|
||||||
|
description: undefined,
|
||||||
|
kind: "directory",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a row when search exactly matches an existing option", () => {
|
||||||
|
const visible = buildVisibleComboboxOptions({
|
||||||
|
options,
|
||||||
|
searchQuery: "/Users/me/project-a",
|
||||||
|
searchable: true,
|
||||||
|
allowCustomValue: true,
|
||||||
|
customValuePrefix: "",
|
||||||
|
customValueKind: "directory",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visible).toEqual([
|
||||||
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combobox above-search ordering", () => {
|
||||||
|
const visible = [
|
||||||
|
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||||
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
||||||
|
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("renders first logical option closest to the search box in above-search mode", () => {
|
||||||
|
const ordered = orderVisibleComboboxOptions(visible, "above-search");
|
||||||
|
expect(ordered.map((option) => option.id)).toEqual([
|
||||||
|
"/Users/me/project-b",
|
||||||
|
"/Users/me/project-a",
|
||||||
|
"/tmp/new-project",
|
||||||
|
]);
|
||||||
|
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps normal top-down order in below-search mode", () => {
|
||||||
|
const ordered = orderVisibleComboboxOptions(visible, "below-search");
|
||||||
|
expect(ordered.map((option) => option.id)).toEqual([
|
||||||
|
"/tmp/new-project",
|
||||||
|
"/Users/me/project-a",
|
||||||
|
"/Users/me/project-b",
|
||||||
|
]);
|
||||||
|
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
export interface ComboboxOptionModel {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
kind?: "directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildVisibleComboboxOptionsInput {
|
||||||
|
options: ComboboxOptionModel[];
|
||||||
|
searchQuery: string;
|
||||||
|
searchable: boolean;
|
||||||
|
allowCustomValue: boolean;
|
||||||
|
customValuePrefix: string;
|
||||||
|
customValueDescription?: string;
|
||||||
|
customValueKind?: "directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowCustomComboboxOption(input: {
|
||||||
|
options: ComboboxOptionModel[];
|
||||||
|
searchQuery: string;
|
||||||
|
searchable: boolean;
|
||||||
|
allowCustomValue: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const sanitizedSearchValue = input.searchQuery.trim();
|
||||||
|
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !input.options.some(
|
||||||
|
(opt) =>
|
||||||
|
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||||
|
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVisibleComboboxOptions(
|
||||||
|
input: BuildVisibleComboboxOptionsInput
|
||||||
|
): ComboboxOptionModel[] {
|
||||||
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||||
|
const filteredOptions = normalizedSearch
|
||||||
|
? input.options.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
opt.description?.toLowerCase().includes(normalizedSearch)
|
||||||
|
)
|
||||||
|
: input.options;
|
||||||
|
|
||||||
|
const sanitizedSearchValue = input.searchQuery.trim();
|
||||||
|
const showCustomOption = shouldShowCustomComboboxOption({
|
||||||
|
options: input.options,
|
||||||
|
searchQuery: input.searchQuery,
|
||||||
|
searchable: input.searchable,
|
||||||
|
allowCustomValue: input.allowCustomValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleOptions: ComboboxOptionModel[] = [];
|
||||||
|
|
||||||
|
if (showCustomOption) {
|
||||||
|
const trimmedPrefix = input.customValuePrefix.trim();
|
||||||
|
const customLabel =
|
||||||
|
trimmedPrefix.length > 0
|
||||||
|
? `${trimmedPrefix} "${sanitizedSearchValue}"`
|
||||||
|
: sanitizedSearchValue;
|
||||||
|
visibleOptions.push({
|
||||||
|
id: sanitizedSearchValue,
|
||||||
|
label: customLabel,
|
||||||
|
description: input.customValueDescription,
|
||||||
|
kind: input.customValueKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleOptions.push(...filteredOptions);
|
||||||
|
return visibleOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderVisibleComboboxOptions(
|
||||||
|
visibleOptions: ComboboxOptionModel[],
|
||||||
|
optionsPosition: "below-search" | "above-search"
|
||||||
|
): ComboboxOptionModel[] {
|
||||||
|
if (optionsPosition !== "above-search") {
|
||||||
|
return visibleOptions;
|
||||||
|
}
|
||||||
|
return [...visibleOptions].reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComboboxFallbackIndex(
|
||||||
|
itemCount: number,
|
||||||
|
optionsPosition: "below-search" | "above-search"
|
||||||
|
): number {
|
||||||
|
if (itemCount <= 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return optionsPosition === "above-search" ? itemCount - 1 : 0;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -19,17 +20,20 @@ import {
|
|||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { Check, Search } from "lucide-react-native";
|
import { Check, Folder, Search } from "lucide-react-native";
|
||||||
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
||||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
import { getNextActiveIndex } from "./combobox-keyboard";
|
||||||
|
import {
|
||||||
|
buildVisibleComboboxOptions,
|
||||||
|
getComboboxFallbackIndex,
|
||||||
|
orderVisibleComboboxOptions,
|
||||||
|
shouldShowCustomComboboxOption,
|
||||||
|
} from "./combobox-options";
|
||||||
|
import type { ComboboxOptionModel } from "./combobox-options";
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export type ComboboxOption = ComboboxOptionModel;
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
@@ -43,14 +47,35 @@ export interface ComboboxProps {
|
|||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
customValuePrefix?: string;
|
customValuePrefix?: string;
|
||||||
customValueDescription?: string;
|
customValueDescription?: string;
|
||||||
|
customValueKind?: "directory";
|
||||||
|
optionsPosition?: "below-search" | "above-search";
|
||||||
title?: string;
|
title?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
|
/**
|
||||||
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||||
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||||
|
*/
|
||||||
|
desktopPreventInitialFlash?: boolean;
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNumericStyleValue(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -110,6 +135,7 @@ function SearchInput({
|
|||||||
export interface ComboboxItemProps {
|
export interface ComboboxItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
kind?: "directory";
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -119,6 +145,7 @@ export interface ComboboxItemProps {
|
|||||||
export function ComboboxItem({
|
export function ComboboxItem({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
kind,
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -136,6 +163,11 @@ export function ComboboxItem({
|
|||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{kind === "directory" ? (
|
||||||
|
<View style={styles.comboboxItemLeadingSlot}>
|
||||||
|
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={styles.comboboxItemContent}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
@@ -152,7 +184,7 @@ export function ComboboxItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
||||||
return <Text style={styles.emptyText}>{children}</Text>;
|
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
@@ -167,21 +199,32 @@ export function Combobox({
|
|||||||
allowCustomValue = false,
|
allowCustomValue = false,
|
||||||
customValuePrefix = "Use",
|
customValuePrefix = "Use",
|
||||||
customValueDescription,
|
customValueDescription,
|
||||||
|
customValueKind,
|
||||||
|
optionsPosition = "below-search",
|
||||||
title = "Select",
|
title = "Select",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
|
desktopPreventInitialFlash = true,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const effectiveOptionsPosition =
|
||||||
|
isMobile ? "below-search" : optionsPosition;
|
||||||
|
const isDesktopAboveSearch =
|
||||||
|
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||||
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||||
|
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||||
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
|
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
@@ -227,7 +270,7 @@ export function Combobox({
|
|||||||
() => [
|
() => [
|
||||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||||
shift({ padding: collisionPadding }),
|
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||||
floatingSize({
|
floatingSize({
|
||||||
padding: collisionPadding,
|
padding: collisionPadding,
|
||||||
apply({ availableWidth, availableHeight, rects }) {
|
apply({ availableWidth, availableHeight, rects }) {
|
||||||
@@ -245,7 +288,7 @@ export function Combobox({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[collisionPadding]
|
[collisionPadding, isDesktopAboveSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { refs, floatingStyles, update } = useFloating({
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
@@ -263,10 +306,57 @@ export function Combobox({
|
|||||||
setReferenceWidth(null);
|
setReferenceWidth(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => update());
|
const raf = requestAnimationFrame(() => void update());
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || isMobile) {
|
||||||
|
setReferenceAtOrigin(false);
|
||||||
|
setReferenceTop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceEl = anchorRef.current;
|
||||||
|
if (!referenceEl) {
|
||||||
|
setReferenceAtOrigin(false);
|
||||||
|
setReferenceTop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
referenceEl.measureInWindow((x, y) => {
|
||||||
|
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||||
|
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const raf = requestAnimationFrame(measure);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
|
||||||
|
|
||||||
|
const floatingTop = toNumericStyleValue(floatingStyles.top);
|
||||||
|
const floatingLeft = toNumericStyleValue(floatingStyles.left);
|
||||||
|
const desktopAboveSearchBottom =
|
||||||
|
isDesktopAboveSearch && referenceTop !== null
|
||||||
|
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||||
|
: null;
|
||||||
|
const hasResolvedDesktopPosition =
|
||||||
|
referenceWidth !== null &&
|
||||||
|
floatingLeft !== null &&
|
||||||
|
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||||
|
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||||
|
const shouldHideDesktopContent =
|
||||||
|
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||||
|
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||||
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
|
? {
|
||||||
|
left: floatingLeft ?? 0,
|
||||||
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
|
}
|
||||||
|
: floatingStyles;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -298,78 +388,105 @@ export function Combobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||||
const filteredOptions = useMemo(() => {
|
|
||||||
if (!normalizedSearch) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
return options.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.description?.toLowerCase().includes(normalizedSearch)
|
|
||||||
);
|
|
||||||
}, [options, normalizedSearch]);
|
|
||||||
|
|
||||||
const sanitizedSearchValue = searchQuery.trim();
|
const sanitizedSearchValue = searchQuery.trim();
|
||||||
const showCustomOption =
|
const showCustomOption = useMemo(
|
||||||
searchable &&
|
() =>
|
||||||
allowCustomValue &&
|
shouldShowCustomComboboxOption({
|
||||||
sanitizedSearchValue.length > 0 &&
|
options,
|
||||||
!options.some(
|
searchQuery,
|
||||||
(opt) =>
|
searchable,
|
||||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
allowCustomValue,
|
||||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
}),
|
||||||
);
|
[allowCustomValue, options, searchQuery, searchable]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleOptions = useMemo(() => {
|
const visibleOptions = useMemo(
|
||||||
const next: Array<{
|
() =>
|
||||||
id: string;
|
buildVisibleComboboxOptions({
|
||||||
label: string;
|
options,
|
||||||
description?: string;
|
searchQuery,
|
||||||
}> = [];
|
searchable,
|
||||||
|
allowCustomValue,
|
||||||
|
customValuePrefix,
|
||||||
|
customValueDescription,
|
||||||
|
customValueKind,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
allowCustomValue,
|
||||||
|
customValueDescription,
|
||||||
|
customValueKind,
|
||||||
|
customValuePrefix,
|
||||||
|
options,
|
||||||
|
searchQuery,
|
||||||
|
searchable,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (showCustomOption) {
|
const orderedVisibleOptions = useMemo(
|
||||||
next.push({
|
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
||||||
id: sanitizedSearchValue,
|
[effectiveOptionsPosition, visibleOptions]
|
||||||
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
|
);
|
||||||
description: customValueDescription,
|
|
||||||
});
|
const pinDesktopOptionsToBottom = useCallback(() => {
|
||||||
|
if (isMobile || effectiveOptionsPosition !== "above-search") {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
||||||
|
});
|
||||||
|
}, [effectiveOptionsPosition, isMobile]);
|
||||||
|
|
||||||
for (const opt of filteredOptions) {
|
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
||||||
next.push({
|
if (!isOpen) {
|
||||||
id: opt.id,
|
return;
|
||||||
label: opt.label,
|
|
||||||
description: opt.description,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
pinDesktopOptionsToBottom();
|
||||||
|
}, [isOpen, pinDesktopOptionsToBottom]);
|
||||||
|
|
||||||
return next;
|
useEffect(() => {
|
||||||
}, [
|
if (!isOpen) {
|
||||||
customValueDescription,
|
return;
|
||||||
customValuePrefix,
|
}
|
||||||
filteredOptions,
|
pinDesktopOptionsToBottom();
|
||||||
sanitizedSearchValue,
|
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
|
||||||
showCustomOption,
|
|
||||||
]);
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen || isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void update();
|
||||||
|
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (!IS_WEB && isMobile) return;
|
if (!IS_WEB && isMobile) return;
|
||||||
|
|
||||||
if (visibleOptions.length === 0) {
|
if (orderedVisibleOptions.length === 0) {
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackIndex = getComboboxFallbackIndex(
|
||||||
|
orderedVisibleOptions.length,
|
||||||
|
effectiveOptionsPosition
|
||||||
|
);
|
||||||
|
|
||||||
if (normalizedSearch) {
|
if (normalizedSearch) {
|
||||||
setActiveIndex(0);
|
setActiveIndex(fallbackIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
|
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
||||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
|
||||||
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
|
}, [
|
||||||
|
effectiveOptionsPosition,
|
||||||
|
isMobile,
|
||||||
|
isOpen,
|
||||||
|
normalizedSearch,
|
||||||
|
value,
|
||||||
|
orderedVisibleOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
@@ -395,7 +512,7 @@ export function Combobox({
|
|||||||
setActiveIndex((currentIndex) =>
|
setActiveIndex((currentIndex) =>
|
||||||
getNextActiveIndex({
|
getNextActiveIndex({
|
||||||
currentIndex,
|
currentIndex,
|
||||||
itemCount: visibleOptions.length,
|
itemCount: orderedVisibleOptions.length,
|
||||||
key,
|
key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -403,11 +520,11 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
if (visibleOptions.length === 0) return;
|
if (orderedVisibleOptions.length === 0) return;
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const index =
|
const index =
|
||||||
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
|
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||||
handleSelect(visibleOptions[index]!.id);
|
handleSelect(orderedVisibleOptions[index]!.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +533,7 @@ export function Combobox({
|
|||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -449,12 +566,13 @@ export function Combobox({
|
|||||||
|
|
||||||
const optionsList = (
|
const optionsList = (
|
||||||
<>
|
<>
|
||||||
{visibleOptions.length > 0 ? (
|
{orderedVisibleOptions.length > 0 ? (
|
||||||
visibleOptions.map((opt, index) => (
|
orderedVisibleOptions.map((opt, index) => (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
|
kind={opt.kind}
|
||||||
selected={opt.id === value}
|
selected={opt.id === value}
|
||||||
active={index === activeIndex}
|
active={index === activeIndex}
|
||||||
onPress={() => handleSelect(opt.id)}
|
onPress={() => handleSelect(opt.id)}
|
||||||
@@ -466,13 +584,16 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = children ?? (
|
const defaultContent = (
|
||||||
<>
|
<>
|
||||||
|
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||||
{searchable ? searchInput : null}
|
{searchable ? searchInput : null}
|
||||||
{optionsList}
|
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const content = children ?? defaultContent;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -514,8 +635,9 @@ export function Combobox({
|
|||||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(100)}
|
testID="combobox-desktop-container"
|
||||||
exiting={FadeOut.duration(100)}
|
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
||||||
|
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||||
style={[
|
style={[
|
||||||
styles.desktopContainer,
|
styles.desktopContainer,
|
||||||
{
|
{
|
||||||
@@ -523,8 +645,8 @@ export function Combobox({
|
|||||||
minWidth: referenceWidth ?? 200,
|
minWidth: referenceWidth ?? 200,
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
},
|
},
|
||||||
floatingStyles,
|
desktopPositionStyle,
|
||||||
referenceWidth === null ? { opacity: 0 } : null,
|
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
@@ -542,15 +664,32 @@ export function Combobox({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{effectiveOptionsPosition === "above-search" ? (
|
||||||
|
<ScrollView
|
||||||
|
ref={desktopOptionsScrollRef}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.desktopScrollContent,
|
||||||
|
styles.desktopScrollContentAboveSearch,
|
||||||
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
style={styles.desktopScroll}
|
||||||
|
onContentSizeChange={handleDesktopOptionsContentSizeChange}
|
||||||
|
>
|
||||||
|
{optionsList}
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
{searchable ? searchInput : null}
|
{searchable ? searchInput : null}
|
||||||
<ScrollView
|
{effectiveOptionsPosition === "below-search" ? (
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
<ScrollView
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.desktopScrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
style={styles.desktopScroll}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
style={styles.desktopScroll}
|
||||||
{optionsList}
|
>
|
||||||
</ScrollView>
|
{optionsList}
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -613,6 +752,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
comboboxItemLeadingSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
comboboxItemLabel: {
|
comboboxItemLabel: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -675,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
maxHeight: 400,
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopScrollContent: {
|
desktopScrollContent: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
desktopScrollContentAboveSearch: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useDaemonConnections } from "./daemon-connections-context";
|
|||||||
import type { ActiveConnection } from "./daemon-connections-context";
|
import type { ActiveConnection } from "./daemon-connections-context";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
|
type Agent,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
type DaemonConnectionSnapshot,
|
type DaemonConnectionSnapshot,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
@@ -850,14 +851,7 @@ export function SessionProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectionSnapshot.isConnected) {
|
if (!connectionSnapshot.isConnected) {
|
||||||
hasBootstrappedAgentUpdatesRef.current = false;
|
hasBootstrappedAgentUpdatesRef.current = false;
|
||||||
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
|
pendingAgentUpdatesRef.current.clear();
|
||||||
if (subscriptionId && client) {
|
|
||||||
try {
|
|
||||||
client.unsubscribeAgentUpdates(subscriptionId);
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
agentUpdatesSubscriptionIdRef.current = null;
|
agentUpdatesSubscriptionIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -866,26 +860,84 @@ export function SessionProvider({
|
|||||||
}
|
}
|
||||||
hasBootstrappedAgentUpdatesRef.current = true;
|
hasBootstrappedAgentUpdatesRef.current = true;
|
||||||
|
|
||||||
try {
|
let cancelled = false;
|
||||||
if (!agentUpdatesSubscriptionIdRef.current) {
|
const requestedSubscriptionId = `app:${serverId}`;
|
||||||
agentUpdatesSubscriptionIdRef.current = client.subscribeAgentUpdates({
|
|
||||||
subscriptionId: `app:${serverId}`,
|
|
||||||
filter: { labels: { ui: "true" } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Session] subscribeAgentUpdates failed", { serverId, err });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session bootstrap is now fully event-driven for agent lists.
|
const bootstrapAgentDirectory = async () => {
|
||||||
setInitializingAgents(serverId, new Map());
|
try {
|
||||||
setHasHydratedAgents(serverId, true);
|
const payload = await client.fetchAgents({
|
||||||
updateConnectionStatus(serverId, {
|
filter: { labels: { ui: "true" } },
|
||||||
status: "online",
|
subscribe: { subscriptionId: requestedSubscriptionId },
|
||||||
lastOnlineAt: new Date().toISOString(),
|
});
|
||||||
agentListReady: true,
|
if (cancelled) {
|
||||||
});
|
return;
|
||||||
}, [connectionSnapshot.isConnected, client, serverId, setHasHydratedAgents, updateConnectionStatus]);
|
}
|
||||||
|
|
||||||
|
agentUpdatesSubscriptionIdRef.current =
|
||||||
|
payload.subscriptionId ?? requestedSubscriptionId;
|
||||||
|
|
||||||
|
const nextAgents = new Map<string, Agent>();
|
||||||
|
const nextPendingPermissions = new Map<
|
||||||
|
string,
|
||||||
|
{ key: string; agentId: string; request: AgentPermissionRequest }
|
||||||
|
>();
|
||||||
|
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||||
|
|
||||||
|
for (const entry of payload.entries) {
|
||||||
|
const agent = {
|
||||||
|
...normalizeAgentSnapshot(entry.agent, serverId),
|
||||||
|
projectPlacement: entry.project,
|
||||||
|
};
|
||||||
|
nextAgents.set(agent.id, agent);
|
||||||
|
nextStatuses.set(agent.id, agent.status);
|
||||||
|
|
||||||
|
for (const request of agent.pendingPermissions) {
|
||||||
|
const key = derivePendingPermissionKey(agent.id, request);
|
||||||
|
nextPendingPermissions.set(key, { key, agentId: agent.id, request });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousAgentStatusRef.current = nextStatuses;
|
||||||
|
pendingAgentUpdatesRef.current.clear();
|
||||||
|
setAgents(serverId, nextAgents);
|
||||||
|
for (const agent of nextAgents.values()) {
|
||||||
|
setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||||
|
}
|
||||||
|
setPendingPermissions(serverId, nextPendingPermissions);
|
||||||
|
setInitializingAgents(serverId, new Map());
|
||||||
|
setHasHydratedAgents(serverId, true);
|
||||||
|
updateConnectionStatus(serverId, {
|
||||||
|
status: "online",
|
||||||
|
lastOnlineAt: new Date().toISOString(),
|
||||||
|
agentListReady: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasBootstrappedAgentUpdatesRef.current = false;
|
||||||
|
pendingAgentUpdatesRef.current.clear();
|
||||||
|
agentUpdatesSubscriptionIdRef.current = null;
|
||||||
|
console.error("[Session] fetchAgents bootstrap failed", { serverId, err });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void bootstrapAgentDirectory();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
connectionSnapshot.isConnected,
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
setAgentLastActivity,
|
||||||
|
setAgents,
|
||||||
|
setHasHydratedAgents,
|
||||||
|
setInitializingAgents,
|
||||||
|
setPendingPermissions,
|
||||||
|
updateConnectionStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
// Daemon message handlers - directly update Zustand store
|
// Daemon message handlers - directly update Zustand store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||||
|
|
||||||
@@ -9,20 +10,43 @@ interface AgentSlashCommand {
|
|||||||
argumentHint: string;
|
argumentHint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandsQueryKey(serverId: string, agentId: string) {
|
export interface DraftCommandConfig {
|
||||||
return ["agentCommands", serverId, agentId] as const;
|
provider: AgentProvider;
|
||||||
|
cwd: string;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandsQueryKey(
|
||||||
|
serverId: string,
|
||||||
|
agentId: string,
|
||||||
|
draftConfig?: DraftCommandConfig
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"agentCommands",
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
draftConfig?.provider ?? null,
|
||||||
|
draftConfig?.cwd ?? null,
|
||||||
|
draftConfig?.modeId ?? null,
|
||||||
|
draftConfig?.model ?? null,
|
||||||
|
draftConfig?.thinkingOptionId ?? null,
|
||||||
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAgentCommandsQueryOptions {
|
interface UseAgentCommandsQueryOptions {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
draftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentCommandsQuery({
|
export function useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
draftConfig,
|
||||||
}: UseAgentCommandsQueryOptions) {
|
}: UseAgentCommandsQueryOptions) {
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
@@ -32,12 +56,12 @@ export function useAgentCommandsQuery({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: commandsQueryKey(serverId, agentId),
|
queryKey: commandsQueryKey(serverId, agentId, draftConfig),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
const response = await client.listCommands(agentId);
|
const response = await client.listCommands(agentId, { draftConfig });
|
||||||
return response.commands as AgentSlashCommand[];
|
return response.commands as AgentSlashCommand[];
|
||||||
},
|
},
|
||||||
enabled: enabled && !!client && isConnected && !!agentId,
|
enabled: enabled && !!client && isConnected && !!agentId,
|
||||||
|
|||||||
@@ -216,9 +216,6 @@ export function useCommandCenter() {
|
|||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agent: AggregatedAgent) => {
|
(agent: AggregatedAgent) => {
|
||||||
didNavigateRef.current = true;
|
didNavigateRef.current = true;
|
||||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
|
||||||
session?.client?.clearAgentAttention(agent.id);
|
|
||||||
|
|
||||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { getIsTauriMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
@@ -34,6 +35,21 @@ function deriveFaviconStatus(
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveMacDockBadgeCount(
|
||||||
|
agents: ReturnType<typeof useAggregatedAgents>["agents"]
|
||||||
|
): number | undefined {
|
||||||
|
const attentionCount = agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.requiresAttention &&
|
||||||
|
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
|
||||||
|
).length;
|
||||||
|
if (attentionCount > 0) {
|
||||||
|
return attentionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||||
const image = FAVICON_IMAGES[colorScheme][status];
|
const image = FAVICON_IMAGES[colorScheme][status];
|
||||||
if (typeof image === "object" && "uri" in image) {
|
if (typeof image === "object" && "uri" in image) {
|
||||||
@@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme {
|
|||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateMacDockBadge(count?: number) {
|
||||||
|
if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return;
|
||||||
|
|
||||||
|
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||||
|
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tauriWindow.setBadgeCount(count);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useFaviconStatus() {
|
export function useFaviconStatus() {
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||||
|
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Listen for system color scheme changes
|
// Listen for system color scheme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,5 +128,11 @@ export function useFaviconStatus() {
|
|||||||
|
|
||||||
const status = deriveFaviconStatus(agents);
|
const status = deriveFaviconStatus(agents);
|
||||||
updateFavicon(status, colorScheme);
|
updateFavicon(status, colorScheme);
|
||||||
|
|
||||||
|
const dockBadgeCount = deriveMacDockBadgeCount(agents);
|
||||||
|
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||||
|
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||||
|
void updateMacDockBadge(dockBadgeCount);
|
||||||
|
}
|
||||||
}, [agents, colorScheme]);
|
}, [agents, colorScheme]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
|
||||||
@@ -13,11 +13,75 @@ interface UseFileDropZoneReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".heic": "image/heic",
|
||||||
|
".heif": "image/heif",
|
||||||
|
".avif": "image/avif",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
};
|
||||||
|
|
||||||
|
type TauriDragDropPayload =
|
||||||
|
| {
|
||||||
|
type: "enter";
|
||||||
|
paths: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "over";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "drop";
|
||||||
|
paths: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "leave";
|
||||||
|
};
|
||||||
|
|
||||||
|
type TauriDragDropEvent = {
|
||||||
|
payload: TauriDragDropPayload;
|
||||||
|
};
|
||||||
|
|
||||||
function isImageFile(file: File): boolean {
|
function isImageFile(file: File): boolean {
|
||||||
return file.type.startsWith("image/");
|
return file.type.startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTauriEnvironment(): boolean {
|
||||||
|
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(path: string): string {
|
||||||
|
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||||
|
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||||
|
if (extensionIndex < 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return normalizedPath.slice(extensionIndex).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImagePath(path: string): boolean {
|
||||||
|
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePathToImageAttachment(path: string): ImageAttachment {
|
||||||
|
const extension = getFileExtension(path);
|
||||||
|
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||||
|
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
|
||||||
|
const uri =
|
||||||
|
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -62,78 +126,167 @@ export function useFileDropZone({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) return;
|
if (!IS_WEB) return;
|
||||||
|
|
||||||
const element = containerRef.current;
|
let disposed = false;
|
||||||
if (!element) return;
|
let cleanup: (() => void) | undefined;
|
||||||
|
let didCleanup = false;
|
||||||
|
|
||||||
function handleDragEnter(e: DragEvent) {
|
function runCleanup(unlisten?: () => void | Promise<void>) {
|
||||||
e.preventDefault();
|
if (didCleanup) return;
|
||||||
e.stopPropagation();
|
const cleanupFn = unlisten ?? cleanup;
|
||||||
|
if (!cleanupFn) return;
|
||||||
if (disabled) return;
|
didCleanup = true;
|
||||||
|
try {
|
||||||
dragCounterRef.current++;
|
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||||
setIsDragging(true);
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
async function setupTauriDragDrop(): Promise<boolean> {
|
||||||
e.preventDefault();
|
if (!isTauriEnvironment()) {
|
||||||
e.stopPropagation();
|
return false;
|
||||||
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.dropEffect = "copy";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave(e: DragEvent) {
|
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||||
e.preventDefault();
|
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||||
e.stopPropagation();
|
return false;
|
||||||
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
dragCounterRef.current--;
|
|
||||||
if (dragCounterRef.current === 0) {
|
|
||||||
setIsDragging(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDrop(e: DragEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
setIsDragging(false);
|
|
||||||
dragCounterRef.current = 0;
|
|
||||||
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
||||||
const imageFiles = files.filter(isImageFile);
|
|
||||||
|
|
||||||
if (imageFiles.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attachments = await Promise.all(
|
const unlisten = await tauriWindow.onDragDropEvent(
|
||||||
imageFiles.map(fileToImageAttachment)
|
(event: TauriDragDropEvent) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
if (payload.type === "leave") {
|
||||||
|
setIsDragging(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "enter" || payload.type === "over") {
|
||||||
|
if (!disabled) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop always ends the current drag operation.
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const imagePaths = payload.paths.filter(isImagePath);
|
||||||
|
if (imagePaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = imagePaths.map(filePathToImageAttachment);
|
||||||
|
onFilesDroppedRef.current(attachments);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
onFilesDroppedRef.current(attachments);
|
|
||||||
|
if (disposed) {
|
||||||
|
runCleanup(unlisten);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = unlisten;
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element.addEventListener("dragenter", handleDragEnter);
|
function setupDomDragDrop() {
|
||||||
element.addEventListener("dragover", handleDragOver);
|
const element = containerRef.current;
|
||||||
element.addEventListener("dragleave", handleDragLeave);
|
if (!element) {
|
||||||
element.addEventListener("drop", handleDrop);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
dragCounterRef.current++;
|
||||||
|
if (e.dataTransfer?.types.includes("Files")) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
dragCounterRef.current--;
|
||||||
|
if (dragCounterRef.current === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
const imageFiles = files.filter(isImageFile);
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
imageFiles.map(fileToImageAttachment)
|
||||||
|
);
|
||||||
|
onFilesDroppedRef.current(attachments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener("dragenter", handleDragEnter);
|
||||||
|
element.addEventListener("dragover", handleDragOver);
|
||||||
|
element.addEventListener("dragleave", handleDragLeave);
|
||||||
|
element.addEventListener("drop", handleDrop);
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
element.removeEventListener("dragenter", handleDragEnter);
|
||||||
|
element.removeEventListener("dragover", handleDragOver);
|
||||||
|
element.removeEventListener("dragleave", handleDragLeave);
|
||||||
|
element.removeEventListener("drop", handleDrop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const tauriListenersAttached = await setupTauriDragDrop();
|
||||||
|
if (disposed || tauriListenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setupDomDragDrop();
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.removeEventListener("dragenter", handleDragEnter);
|
disposed = true;
|
||||||
element.removeEventListener("dragover", handleDragOver);
|
runCleanup();
|
||||||
element.removeEventListener("dragleave", handleDragLeave);
|
|
||||||
element.removeEventListener("drop", handleDrop);
|
|
||||||
};
|
};
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
normalizeAgentSnapshot,
|
normalizeAgentSnapshot,
|
||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||||
|
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -514,6 +515,9 @@ function AgentScreenContent({
|
|||||||
const isConnected = useSessionStore(
|
const isConnected = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||||
);
|
);
|
||||||
|
const focusedAgentId = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||||
|
);
|
||||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||||
kind: "idle",
|
kind: "idle",
|
||||||
@@ -894,25 +898,30 @@ function AgentScreenContent({
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [agent?.title]);
|
}, [agent?.title]);
|
||||||
|
|
||||||
// Track previous agent status to detect completion while viewing
|
// Clear attention as soon as the user is focused on this agent screen.
|
||||||
const previousStatusRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Clear attention when agent finishes while user is viewing this screen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedAgentId || !agent || !client) {
|
const clearAgentId = resolvedAgentId?.trim();
|
||||||
|
if (!clearAgentId || !client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
const previousStatus = previousStatusRef.current;
|
!shouldClearAgentAttentionOnView({
|
||||||
const currentStatus = agent.status;
|
agentId: clearAgentId,
|
||||||
previousStatusRef.current = currentStatus;
|
focusedAgentId,
|
||||||
|
isConnected,
|
||||||
// If agent transitioned from running to idle while we're viewing,
|
requiresAttention: agent?.requiresAttention,
|
||||||
// immediately clear attention since user witnessed the completion
|
})
|
||||||
if (previousStatus === "running" && currentStatus === "idle") {
|
) {
|
||||||
client.clearAgentAttention(resolvedAgentId);
|
return;
|
||||||
}
|
}
|
||||||
}, [resolvedAgentId, agent?.status, client]);
|
client.clearAgentAttention(clearAgentId);
|
||||||
|
}, [
|
||||||
|
agent?.requiresAttention,
|
||||||
|
client,
|
||||||
|
focusedAgentId,
|
||||||
|
isConnected,
|
||||||
|
resolvedAgentId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRefreshAgent = useCallback(() => {
|
const handleRefreshAgent = useCallback(() => {
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId) {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
|
import { Folder, GitBranch } from "lucide-react-native";
|
||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||||
@@ -23,15 +23,18 @@ import { Combobox } from "@/components/ui/combobox";
|
|||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||||
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
@@ -128,9 +131,6 @@ export function DraftAgentScreen({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const { daemons } = useDaemonRegistry();
|
const { daemons } = useDaemonRegistry();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
||||||
const params = useLocalSearchParams<DraftAgentParams>();
|
const params = useLocalSearchParams<DraftAgentParams>();
|
||||||
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -229,11 +229,6 @@ export function DraftAgentScreen({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
const SidebarIcon = isMobile ? Menu : PanelLeft;
|
|
||||||
const sidebarIconColor = !isMobile && isSidebarOpen
|
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
@@ -244,6 +239,8 @@ export function DraftAgentScreen({
|
|||||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||||
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||||
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||||
|
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
|
||||||
|
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
|
||||||
const workingDirAnchorRef = useRef<View>(null);
|
const workingDirAnchorRef = useRef<View>(null);
|
||||||
const worktreeAnchorRef = useRef<View>(null);
|
const worktreeAnchorRef = useRef<View>(null);
|
||||||
const branchAnchorRef = useRef<View>(null);
|
const branchAnchorRef = useRef<View>(null);
|
||||||
@@ -258,6 +255,12 @@ export function DraftAgentScreen({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [branchSearchQuery]);
|
}, [branchSearchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = workingDirSearchQuery.trim();
|
||||||
|
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [workingDirSearchQuery]);
|
||||||
|
|
||||||
type CreateAttempt = {
|
type CreateAttempt = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -320,6 +323,7 @@ export function DraftAgentScreen({
|
|||||||
const sessionAgents = useSessionStore((state) =>
|
const sessionAgents = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
||||||
);
|
);
|
||||||
|
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
|
||||||
const worktreePathLastCreatedAt = useMemo(() => {
|
const worktreePathLastCreatedAt = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
if (!sessionAgents) {
|
if (!sessionAgents) {
|
||||||
@@ -338,24 +342,23 @@ export function DraftAgentScreen({
|
|||||||
return map;
|
return map;
|
||||||
}, [sessionAgents]);
|
}, [sessionAgents]);
|
||||||
const agentWorkingDirSuggestions = useMemo(() => {
|
const agentWorkingDirSuggestions = useMemo(() => {
|
||||||
if (!selectedServerId || !sessionAgents) {
|
const liveSources = sessionAgents
|
||||||
return [];
|
? Array.from(sessionAgents.values()).map((agent) => ({
|
||||||
}
|
cwd: agent.cwd,
|
||||||
const pathLastCreated = new Map<string, Date>();
|
createdAt: agent.createdAt,
|
||||||
sessionAgents.forEach((agent) => {
|
lastActivityAt: agent.lastActivityAt,
|
||||||
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
|
}))
|
||||||
const existing = pathLastCreated.get(agent.cwd);
|
: [];
|
||||||
if (!existing || agent.createdAt > existing) {
|
const fetchedSources = allAgents.map((agent) => ({
|
||||||
pathLastCreated.set(agent.cwd, agent.createdAt);
|
cwd: agent.cwd,
|
||||||
}
|
lastActivityAt: agent.lastActivityAt,
|
||||||
}
|
}));
|
||||||
});
|
|
||||||
return Array.from(pathLastCreated.keys()).sort((a, b) => {
|
return collectAgentWorkingDirectorySuggestions([
|
||||||
const aTime = pathLastCreated.get(a)!.getTime();
|
...liveSources,
|
||||||
const bTime = pathLastCreated.get(b)!.getTime();
|
...fetchedSources,
|
||||||
return bTime - aTime;
|
]);
|
||||||
});
|
}, [allAgents, sessionAgents]);
|
||||||
}, [selectedServerId, sessionAgents]);
|
|
||||||
|
|
||||||
const sessionClient = useSessionStore((state) =>
|
const sessionClient = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||||
@@ -524,6 +527,36 @@ export function DraftAgentScreen({
|
|||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directorySuggestionsQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"directorySuggestions",
|
||||||
|
selectedServerId,
|
||||||
|
debouncedWorkingDirSearchQuery,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = sessionClient;
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client unavailable");
|
||||||
|
}
|
||||||
|
const payload = await client.getDirectorySuggestions({
|
||||||
|
query: debouncedWorkingDirSearchQuery,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return payload.directories ?? [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Boolean(debouncedWorkingDirSearchQuery) &&
|
||||||
|
Boolean(selectedServerId) &&
|
||||||
|
!daemonAvailabilityError &&
|
||||||
|
Boolean(sessionClient) &&
|
||||||
|
isConnected,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
const validateWorktreeName = useCallback(
|
const validateWorktreeName = useCallback(
|
||||||
(name: string): { valid: boolean; error?: string } => {
|
(name: string): { valid: boolean; error?: string } => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -657,6 +690,50 @@ export function DraftAgentScreen({
|
|||||||
|
|
||||||
const selectedWorktreeLabel =
|
const selectedWorktreeLabel =
|
||||||
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
|
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
|
||||||
|
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
|
||||||
|
const workingDirSearchError =
|
||||||
|
directorySuggestionsQuery.error instanceof Error
|
||||||
|
? directorySuggestionsQuery.error.message
|
||||||
|
: null;
|
||||||
|
const workingDirSuggestionPaths = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWorkingDirectorySuggestions({
|
||||||
|
recommendedPaths: agentWorkingDirSuggestions,
|
||||||
|
serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [],
|
||||||
|
query: workingDirSearchQuery,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
agentWorkingDirSuggestions,
|
||||||
|
directorySuggestionsQuery.data,
|
||||||
|
hasWorkingDirectorySearch,
|
||||||
|
workingDirSearchQuery,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const workingDirComboOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
workingDirSuggestionPaths.map((path) => ({
|
||||||
|
id: path,
|
||||||
|
label: shortenPath(path),
|
||||||
|
kind: "directory" as const,
|
||||||
|
})),
|
||||||
|
[workingDirSuggestionPaths]
|
||||||
|
);
|
||||||
|
const workingDirEmptyText = useMemo(() => {
|
||||||
|
if (hasWorkingDirectorySearch) {
|
||||||
|
if (workingDirSearchError) {
|
||||||
|
return "Failed to search directories on this host.";
|
||||||
|
}
|
||||||
|
return "No directories match your search.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentWorkingDirSuggestions.length > 0
|
||||||
|
? "No agent directories match your search."
|
||||||
|
: "We'll suggest directories from agents on this host once they exist.";
|
||||||
|
}, [
|
||||||
|
agentWorkingDirSuggestions.length,
|
||||||
|
hasWorkingDirectorySearch,
|
||||||
|
workingDirSearchError,
|
||||||
|
]);
|
||||||
const displayWorkingDir = shortenPath(workingDir);
|
const displayWorkingDir = shortenPath(workingDir);
|
||||||
const worktreeTriggerValue =
|
const worktreeTriggerValue =
|
||||||
worktreeMode === "create"
|
worktreeMode === "create"
|
||||||
@@ -716,6 +793,33 @@ export function DraftAgentScreen({
|
|||||||
const createAgentClient = useSessionStore((state) =>
|
const createAgentClient = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||||
);
|
);
|
||||||
|
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||||
|
const cwd = (
|
||||||
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||||
|
).trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd,
|
||||||
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
|
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||||
|
...(selectedThinkingOptionId.trim()
|
||||||
|
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isAttachWorktree,
|
||||||
|
modeOptions.length,
|
||||||
|
selectedMode,
|
||||||
|
selectedModel,
|
||||||
|
selectedProvider,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
selectedWorktreePath,
|
||||||
|
workingDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const promptValue = machine.tag === "draft" ? machine.promptText : "";
|
const promptValue = machine.tag === "draft" ? machine.promptText : "";
|
||||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||||
@@ -988,20 +1092,7 @@ export function DraftAgentScreen({
|
|||||||
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<HeaderToggleButton
|
<SidebarMenuToggle />
|
||||||
onPress={toggleAgentList}
|
|
||||||
tooltipLabel="Toggle sidebar"
|
|
||||||
tooltipKeys={["mod", "B"]}
|
|
||||||
tooltipSide="right"
|
|
||||||
testID="menu-button"
|
|
||||||
nativeID="menu-button"
|
|
||||||
accessible
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
|
|
||||||
accessibilityState={{ expanded: isSidebarOpen }}
|
|
||||||
>
|
|
||||||
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
|
|
||||||
</HeaderToggleButton>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
||||||
@@ -1133,21 +1224,16 @@ export function DraftAgentScreen({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
options={agentWorkingDirSuggestions.map((path) => ({
|
options={workingDirComboOptions}
|
||||||
id: path,
|
|
||||||
label: shortenPath(path),
|
|
||||||
}))}
|
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
onSelect={setWorkingDirFromUser}
|
onSelect={setWorkingDirFromUser}
|
||||||
searchPlaceholder="/path/to/project"
|
onSearchQueryChange={setWorkingDirSearchQuery}
|
||||||
emptyText={
|
searchPlaceholder="Search directories..."
|
||||||
agentWorkingDirSuggestions.length > 0
|
emptyText={workingDirEmptyText}
|
||||||
? "No agent directories match your search."
|
|
||||||
: "We'll suggest directories from agents on this host once they exist."
|
|
||||||
}
|
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
customValuePrefix="Use"
|
customValuePrefix=""
|
||||||
customValueDescription="Launch the agent in this directory"
|
customValueKind="directory"
|
||||||
|
optionsPosition="above-search"
|
||||||
title="Working directory"
|
title="Working directory"
|
||||||
open={isWorkingDirOpen}
|
open={isWorkingDirOpen}
|
||||||
onOpenChange={setIsWorkingDirOpen}
|
onOpenChange={setIsWorkingDirOpen}
|
||||||
@@ -1193,6 +1279,7 @@ export function DraftAgentScreen({
|
|||||||
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
|
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
|
||||||
autoFocus={machine.tag === "draft"}
|
autoFocus={machine.tag === "draft"}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
|
commandDraftConfig={draftCommandConfig}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont
|
|||||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -76,7 +77,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
letterSpacing: 0.6,
|
letterSpacing: 0.6,
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[3],
|
||||||
marginLeft: theme.spacing[1],
|
marginLeft: theme.spacing[1],
|
||||||
},
|
},
|
||||||
@@ -983,29 +983,21 @@ function HostDetailModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
void confirmDialog({
|
||||||
const hasBrowserConfirm =
|
title: `Restart ${host.label}`,
|
||||||
typeof globalThis !== "undefined" &&
|
message: restartConfirmationMessage,
|
||||||
typeof (globalThis as any).confirm === "function";
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
const confirmed = hasBrowserConfirm
|
destructive: true,
|
||||||
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
|
}).then((confirmed) => {
|
||||||
: true;
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
if (confirmed) {
|
|
||||||
beginServerRestart();
|
|
||||||
}
|
}
|
||||||
return;
|
beginServerRestart();
|
||||||
}
|
}).catch((error) => {
|
||||||
|
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
|
||||||
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
|
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||||
{ text: "Cancel", style: "cancel" },
|
});
|
||||||
{
|
|
||||||
text: "Restart",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: beginServerRestart,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
||||||
|
|
||||||
// Status display
|
// Status display
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
|
|||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
@@ -303,7 +304,7 @@ function buildDownloadUrl(
|
|||||||
function triggerBrowserDownload(url: string, fileName: string) {
|
function triggerBrowserDownload(url: string, fileName: string) {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.open(url, "_blank", "noopener");
|
void openExternalUrl(url);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||||
|
|
||||||
|
type StubTerminal = {
|
||||||
|
write: (text: string, callback?: () => void) => void;
|
||||||
|
reset: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntimeWithTerminal(): {
|
||||||
|
runtime: TerminalEmulatorRuntime;
|
||||||
|
terminal: StubTerminal & {
|
||||||
|
resetCalls: number;
|
||||||
|
};
|
||||||
|
writeCallbacks: Array<() => void>;
|
||||||
|
writeTexts: string[];
|
||||||
|
} {
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
const writeCallbacks: Array<() => void> = [];
|
||||||
|
const writeTexts: string[] = [];
|
||||||
|
let resetCalls = 0;
|
||||||
|
|
||||||
|
const terminal: StubTerminal & { resetCalls: number } = {
|
||||||
|
write: (text: string, callback?: () => void) => {
|
||||||
|
writeTexts.push(text);
|
||||||
|
if (callback) {
|
||||||
|
writeCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
resetCalls += 1;
|
||||||
|
terminal.resetCalls = resetCalls;
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
resetCalls,
|
||||||
|
};
|
||||||
|
|
||||||
|
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
terminal,
|
||||||
|
writeCallbacks,
|
||||||
|
writeTexts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("terminal-emulator-runtime", () => {
|
||||||
|
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
|
||||||
|
__paseoTerminal: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as { window?: unknown }).window = originalWindow;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes write and clear operations in strict order", () => {
|
||||||
|
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
|
||||||
|
const committed: string[] = [];
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "first",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("first");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.clear({
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("clear");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "second",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("second");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeTexts).toEqual(["first"]);
|
||||||
|
expect(terminal.resetCalls).toBe(0);
|
||||||
|
expect(committed).toEqual([]);
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
|
||||||
|
expect(committed).toEqual(["first", "clear"]);
|
||||||
|
expect(terminal.resetCalls).toBe(1);
|
||||||
|
expect(writeTexts).toEqual(["first", "second"]);
|
||||||
|
|
||||||
|
writeCallbacks[1]?.();
|
||||||
|
expect(committed).toEqual(["first", "clear", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to timeout commit when xterm write callback does not fire", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { runtime } = createRuntimeWithTerminal();
|
||||||
|
const onCommitted = vi.fn();
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "stuck",
|
||||||
|
onCommitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onCommitted).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
||||||
|
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||||
|
const committed: string[] = [];
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "first",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("first");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "second",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("second");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
expect(committed).toEqual(["first"]);
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
expect(committed).toEqual(["first"]);
|
||||||
|
|
||||||
|
writeCallbacks[1]?.();
|
||||||
|
expect(committed).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits pending output operations during unmount to avoid deadlock", () => {
|
||||||
|
const { runtime } = createRuntimeWithTerminal();
|
||||||
|
const onCommittedA = vi.fn();
|
||||||
|
const onCommittedB = vi.fn();
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "a",
|
||||||
|
onCommitted: onCommittedA,
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "b",
|
||||||
|
onCommitted: onCommittedB,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.unmount();
|
||||||
|
|
||||||
|
expect(onCommittedA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommittedB).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces a refit when resize is requested", () => {
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
const fitAndEmitResize = vi.fn();
|
||||||
|
|
||||||
|
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||||
|
fitAndEmitResize;
|
||||||
|
|
||||||
|
runtime.resize();
|
||||||
|
runtime.resize({ force: true });
|
||||||
|
|
||||||
|
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false);
|
||||||
|
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,12 @@ type TerminalEmulatorRuntimeDisposables = {
|
|||||||
disposeTerminal: () => void;
|
disposeTerminal: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TerminalOutputOperation = {
|
||||||
|
type: "write" | "clear";
|
||||||
|
text: string;
|
||||||
|
onCommitted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__paseoTerminal?: Terminal;
|
__paseoTerminal?: Terminal;
|
||||||
@@ -59,6 +65,7 @@ declare global {
|
|||||||
|
|
||||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
|
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
export class TerminalEmulatorRuntime {
|
export class TerminalEmulatorRuntime {
|
||||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||||
@@ -69,10 +76,12 @@ export class TerminalEmulatorRuntime {
|
|||||||
};
|
};
|
||||||
private terminal: Terminal | null = null;
|
private terminal: Terminal | null = null;
|
||||||
private fitAddon: FitAddon | null = null;
|
private fitAddon: FitAddon | null = null;
|
||||||
|
private fitAndEmitResize: ((force: boolean) => void) | null = null;
|
||||||
private lastSize: { rows: number; cols: number } | null = null;
|
private lastSize: { rows: number; cols: number } | null = null;
|
||||||
private cleanup: (() => void) | null = null;
|
private cleanup: (() => void) | null = null;
|
||||||
private pendingWriteText = "";
|
private outputOperations: TerminalOutputOperation[] = [];
|
||||||
private isWriteFlushQueued = false;
|
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||||
|
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||||
this.callbacks = input.callbacks;
|
this.callbacks = input.callbacks;
|
||||||
@@ -160,6 +169,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
cols: nextCols,
|
cols: nextCols,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
this.fitAndEmitResize = fitAndEmitResize;
|
||||||
|
|
||||||
fitAndEmitResize(true);
|
fitAndEmitResize(true);
|
||||||
|
|
||||||
@@ -287,6 +297,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.processOutputQueue();
|
||||||
|
|
||||||
const disposables: TerminalEmulatorRuntimeDisposables = {
|
const disposables: TerminalEmulatorRuntimeDisposables = {
|
||||||
disposeInput: () => {
|
disposeInput: () => {
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
@@ -338,30 +350,43 @@ export class TerminalEmulatorRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
write(input: { text: string }): void {
|
write(input: { text: string; onCommitted?: () => void }): void {
|
||||||
if (!this.terminal || input.text.length === 0) {
|
if (input.text.length === 0) {
|
||||||
|
input.onCommitted?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pendingWriteText += input.text;
|
this.outputOperations.push({
|
||||||
|
type: "write",
|
||||||
|
text: input.text,
|
||||||
|
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:enqueue",
|
event: "output:enqueue",
|
||||||
details: {
|
details: {
|
||||||
chunkLength: input.text.length,
|
chunkLength: input.text.length,
|
||||||
queueLength: this.pendingWriteText.length,
|
queueLength: this.outputOperations.length,
|
||||||
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.scheduleWriteFlush();
|
this.processOutputQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(input?: { onCommitted?: () => void }): void {
|
||||||
this.pendingWriteText = "";
|
this.outputOperations.push({
|
||||||
|
type: "clear",
|
||||||
|
text: "",
|
||||||
|
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:clear",
|
event: "output:clear",
|
||||||
});
|
});
|
||||||
this.terminal?.reset();
|
this.processOutputQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(input?: { force?: boolean }): void {
|
||||||
|
this.fitAndEmitResize?.(input?.force ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
@@ -373,11 +398,19 @@ export class TerminalEmulatorRuntime {
|
|||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "mount:unmount",
|
event: "mount:unmount",
|
||||||
details: {
|
details: {
|
||||||
pendingWriteLength: this.pendingWriteText.length,
|
pendingWriteOperations: this.outputOperations.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.pendingWriteText = "";
|
this.clearInFlightOutputTimeout();
|
||||||
this.isWriteFlushQueued = false;
|
const inFlightOperation = this.inFlightOutputOperation;
|
||||||
|
this.inFlightOutputOperation = null;
|
||||||
|
if (inFlightOperation?.onCommitted) {
|
||||||
|
inFlightOperation.onCommitted();
|
||||||
|
}
|
||||||
|
const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length);
|
||||||
|
for (const operation of pendingOperations) {
|
||||||
|
operation.onCommitted?.();
|
||||||
|
}
|
||||||
|
|
||||||
this.cleanup?.();
|
this.cleanup?.();
|
||||||
this.cleanup = null;
|
this.cleanup = null;
|
||||||
@@ -386,27 +419,43 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
this.terminal = null;
|
this.terminal = null;
|
||||||
this.fitAddon = null;
|
this.fitAddon = null;
|
||||||
|
this.fitAndEmitResize = null;
|
||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleWriteFlush(): void {
|
private processOutputQueue(): void {
|
||||||
if (this.isWriteFlushQueued) {
|
if (this.inFlightOutputOperation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isWriteFlushQueued = true;
|
const terminal = this.terminal;
|
||||||
queueMicrotask(() => {
|
if (!terminal) {
|
||||||
this.isWriteFlushQueued = false;
|
|
||||||
this.flushWriteQueue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushWriteQueue(): void {
|
|
||||||
if (!this.terminal || this.pendingWriteText.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = this.pendingWriteText;
|
|
||||||
this.pendingWriteText = "";
|
const operation = this.outputOperations.shift();
|
||||||
|
if (!operation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlightOutputOperation = operation;
|
||||||
|
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
||||||
|
if (this.inFlightOutputOperation !== expectedOperation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inFlightOutputOperation = null;
|
||||||
|
this.clearInFlightOutputTimeout();
|
||||||
|
expectedOperation.onCommitted?.();
|
||||||
|
this.processOutputQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (operation.type === "clear") {
|
||||||
|
terminal.reset();
|
||||||
|
finalizeOperation(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = operation.text;
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:flush",
|
event: "output:flush",
|
||||||
@@ -415,7 +464,25 @@ export class TerminalEmulatorRuntime {
|
|||||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.terminal.write(text);
|
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||||
|
finalizeOperation(operation);
|
||||||
|
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
terminal.write(text, () => {
|
||||||
|
finalizeOperation(operation);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
finalizeOperation(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInFlightOutputTimeout(): void {
|
||||||
|
if (!this.inFlightOutputOperationTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(this.inFlightOutputOperationTimeout);
|
||||||
|
this.inFlightOutputOperationTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
|
||||||
|
|
||||||
|
describe("terminal-output-delivery-queue", () => {
|
||||||
|
it("retries in-flight delivery when consume is missing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
deliveryTimeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 2, text: "b" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
queue.enqueue({ sequence: 3, text: "c" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "bc" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale consume acknowledgements", () => {
|
||||||
|
const delivered = vi.fn();
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "x" });
|
||||||
|
queue.consume({ sequence: 99 });
|
||||||
|
queue.enqueue({ sequence: 2, text: "y" });
|
||||||
|
|
||||||
|
expect(delivered).toHaveBeenCalledTimes(1);
|
||||||
|
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
|
||||||
|
expect(delivered).toHaveBeenCalledTimes(2);
|
||||||
|
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets in-flight and pending chunks", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "hello" });
|
||||||
|
queue.enqueue({ sequence: 2, text: " world" });
|
||||||
|
|
||||||
|
queue.reset();
|
||||||
|
queue.enqueue({ sequence: 3, text: "next" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "hello" },
|
||||||
|
{ sequence: 3, text: "next" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves empty chunk payloads for authoritative clears", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "abc" });
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
queue.enqueue({ sequence: 2, text: "" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "abc" },
|
||||||
|
{ sequence: 2, text: "" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
queue.enqueue({ sequence: 3, text: "" });
|
||||||
|
queue.enqueue({ sequence: 4, text: "c" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 3 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "" },
|
||||||
|
{ sequence: 4, text: "c" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||||
|
|
||||||
|
export type TerminalOutputDeliveryChunk = {
|
||||||
|
sequence: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalOutputDeliveryQueueOptions = {
|
||||||
|
onDeliver: (chunk: TerminalOutputDeliveryChunk) => void;
|
||||||
|
deliveryTimeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
export class TerminalOutputDeliveryQueue {
|
||||||
|
private readonly pendingChunks: TerminalOutputDeliveryChunk[] = [];
|
||||||
|
private inFlightChunk: TerminalOutputDeliveryChunk | null = null;
|
||||||
|
private inFlightTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastSeenSequence = 0;
|
||||||
|
private readonly deliveryTimeoutMs: number;
|
||||||
|
|
||||||
|
constructor(private readonly options: TerminalOutputDeliveryQueueOptions) {
|
||||||
|
this.deliveryTimeoutMs =
|
||||||
|
options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(chunk: TerminalOutputDeliveryChunk): void {
|
||||||
|
if (chunk.sequence <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunk.sequence <= this.lastSeenSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastSeenSequence = chunk.sequence;
|
||||||
|
|
||||||
|
if (chunk.text.length === 0) {
|
||||||
|
this.pendingChunks.length = 0;
|
||||||
|
this.pendingChunks.push(chunk);
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "enqueue:clear",
|
||||||
|
details: {
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tryDeliver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
|
||||||
|
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
|
||||||
|
lastPendingChunk.sequence = chunk.sequence;
|
||||||
|
lastPendingChunk.text += chunk.text;
|
||||||
|
} else {
|
||||||
|
this.pendingChunks.push(chunk);
|
||||||
|
}
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "enqueue:text",
|
||||||
|
details: {
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
textLength: chunk.text.length,
|
||||||
|
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tryDeliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(input: { sequence: number }): void {
|
||||||
|
if (this.inFlightChunk?.sequence !== input.sequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "consume",
|
||||||
|
details: {
|
||||||
|
sequence: input.sequence,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.inFlightChunk = null;
|
||||||
|
this.tryDeliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.pendingChunks.length = 0;
|
||||||
|
this.inFlightChunk = null;
|
||||||
|
this.lastSeenSequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryDeliver(): void {
|
||||||
|
if (this.inFlightChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextChunk = this.pendingChunks.shift();
|
||||||
|
if (!nextChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlightChunk = nextChunk;
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "deliver:start",
|
||||||
|
details: {
|
||||||
|
sequence: nextChunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.deliverInFlightChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliverInFlightChunk(): void {
|
||||||
|
const chunk = this.inFlightChunk;
|
||||||
|
if (!chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.inFlightTimeout = setTimeout(() => {
|
||||||
|
if (!this.inFlightChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "deliver:timeout-retry",
|
||||||
|
details: {
|
||||||
|
sequence: this.inFlightChunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
timeoutMs: this.deliveryTimeoutMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.deliverInFlightChunk();
|
||||||
|
}, this.deliveryTimeoutMs);
|
||||||
|
|
||||||
|
this.options.onDeliver({
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
text: chunk.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInFlightTimeout(): void {
|
||||||
|
if (!this.inFlightTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(this.inFlightTimeout);
|
||||||
|
this.inFlightTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
|
|||||||
|
|
||||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
||||||
|
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
||||||
public attachCalls: Array<{
|
public attachCalls: Array<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
options?: {
|
options?: {
|
||||||
@@ -47,6 +48,14 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
streamId: number,
|
streamId: number,
|
||||||
handler: (chunk: TerminalStreamControllerChunk) => void
|
handler: (chunk: TerminalStreamControllerChunk) => void
|
||||||
): () => void {
|
): () => void {
|
||||||
|
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
|
||||||
|
if (pendingChunks && pendingChunks.length > 0) {
|
||||||
|
for (const chunk of pendingChunks) {
|
||||||
|
handler(chunk);
|
||||||
|
}
|
||||||
|
this.pendingChunksByStreamId.delete(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
||||||
subscribers.add(handler);
|
subscribers.add(handler);
|
||||||
this.streamSubscribers.set(streamId, subscribers);
|
this.streamSubscribers.set(streamId, subscribers);
|
||||||
@@ -61,21 +70,44 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
|
|
||||||
emitChunk(input: {
|
emitChunk(input: {
|
||||||
streamId: number;
|
streamId: number;
|
||||||
|
offset?: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
data: string;
|
data: string;
|
||||||
}): void {
|
}): void {
|
||||||
const subscribers = this.streamSubscribers.get(input.streamId);
|
const subscribers = this.streamSubscribers.get(input.streamId);
|
||||||
if (!subscribers || subscribers.size === 0) {
|
if (!subscribers || subscribers.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const bytes = new TextEncoder().encode(input.data);
|
||||||
const chunk: TerminalStreamControllerChunk = {
|
const chunk: TerminalStreamControllerChunk = {
|
||||||
|
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||||
endOffset: input.endOffset,
|
endOffset: input.endOffset,
|
||||||
data: new TextEncoder().encode(input.data),
|
replay: input.replay,
|
||||||
|
data: bytes,
|
||||||
};
|
};
|
||||||
for (const subscriber of subscribers) {
|
for (const subscriber of subscribers) {
|
||||||
subscriber(chunk);
|
subscriber(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bufferChunk(input: {
|
||||||
|
streamId: number;
|
||||||
|
offset?: number;
|
||||||
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
|
data: string;
|
||||||
|
}): void {
|
||||||
|
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
|
||||||
|
const bytes = new TextEncoder().encode(input.data);
|
||||||
|
chunks.push({
|
||||||
|
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||||
|
endOffset: input.endOffset,
|
||||||
|
replay: input.replay,
|
||||||
|
data: bytes,
|
||||||
|
});
|
||||||
|
this.pendingChunksByStreamId.set(input.streamId, chunks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerHarness(input?: {
|
function createControllerHarness(input?: {
|
||||||
@@ -251,4 +283,203 @@ describe("terminal-stream-controller", () => {
|
|||||||
expect(harness.resets).toEqual(["term-reset"]);
|
expect(harness.resets).toEqual(["term-reset"]);
|
||||||
expect(harness.controller.getActiveStreamId()).toBe(12);
|
expect(harness.controller.getActiveStreamId()).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 17,
|
||||||
|
replayedFrom: 0,
|
||||||
|
currentOffset: 10,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.bufferChunk({
|
||||||
|
streamId: 17,
|
||||||
|
offset: 0,
|
||||||
|
endOffset: 10,
|
||||||
|
replay: true,
|
||||||
|
data: "buffered-replay",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-buffered" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.chunks).toEqual([
|
||||||
|
{ terminalId: "term-buffered", text: "buffered-replay" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 61,
|
||||||
|
replayedFrom: 0,
|
||||||
|
currentOffset: 20,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 31,
|
||||||
|
currentOffset: 0,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 32,
|
||||||
|
currentOffset: 8,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-gap" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 31,
|
||||||
|
offset: 0,
|
||||||
|
endOffset: 2,
|
||||||
|
data: "ok",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 31,
|
||||||
|
offset: 4,
|
||||||
|
endOffset: 8,
|
||||||
|
data: "miss",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
|
||||||
|
expect(harness.client.detachCalls).toContain(31);
|
||||||
|
expect(harness.client.attachCalls.length).toBe(2);
|
||||||
|
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||||
|
resumeOffset: 2,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat replay range before currentOffset as a live gap", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 41,
|
||||||
|
replayedFrom: 50,
|
||||||
|
currentOffset: 100,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.bufferChunk({
|
||||||
|
streamId: 41,
|
||||||
|
offset: 80,
|
||||||
|
endOffset: 100,
|
||||||
|
data: "replay-tail",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-replay" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 41,
|
||||||
|
offset: 100,
|
||||||
|
endOffset: 102,
|
||||||
|
data: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.client.detachCalls).toEqual([]);
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(1);
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(41);
|
||||||
|
expect(harness.chunks).toEqual([
|
||||||
|
{ terminalId: "term-replay", text: "replay-tail" },
|
||||||
|
{ terminalId: "term-replay", text: "ok" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 71,
|
||||||
|
replayedFrom: 205,
|
||||||
|
currentOffset: 205,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 72,
|
||||||
|
replayedFrom: 396,
|
||||||
|
currentOffset: 978,
|
||||||
|
reset: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 71,
|
||||||
|
offset: 205,
|
||||||
|
endOffset: 299,
|
||||||
|
data: "first",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 71,
|
||||||
|
offset: 396,
|
||||||
|
endOffset: 493,
|
||||||
|
data: "gap",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(2);
|
||||||
|
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||||
|
resumeOffset: 299,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(72);
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 396,
|
||||||
|
endOffset: 493,
|
||||||
|
data: "replay-1",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 493,
|
||||||
|
endOffset: 590,
|
||||||
|
data: "replay-2",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 687,
|
||||||
|
endOffset: 784,
|
||||||
|
data: "replay-3",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: false,
|
||||||
|
offset: 978,
|
||||||
|
endOffset: 1075,
|
||||||
|
data: "live",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(2);
|
||||||
|
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
|
||||||
|
expect(harness.chunks.at(-1)).toEqual({
|
||||||
|
terminalId: "term-clamped-replay",
|
||||||
|
text: "live",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
|||||||
|
|
||||||
export type TerminalStreamControllerAttachPayload = {
|
export type TerminalStreamControllerAttachPayload = {
|
||||||
streamId: number | null;
|
streamId: number | null;
|
||||||
|
replayedFrom?: number;
|
||||||
currentOffset: number;
|
currentOffset: number;
|
||||||
reset: boolean;
|
reset: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalStreamControllerChunk = {
|
export type TerminalStreamControllerChunk = {
|
||||||
|
offset: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +74,8 @@ type TerminalStreamControllerActiveStream = {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
decoder: TextDecoder;
|
decoder: TextDecoder;
|
||||||
|
nextExpectedOffset: number | null;
|
||||||
|
catchUpEndOffset: number | null;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -308,6 +313,37 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const streamId = attachPayload.streamId;
|
const streamId = attachPayload.streamId;
|
||||||
|
const replayedFromOffset =
|
||||||
|
typeof attachPayload.replayedFrom === "number"
|
||||||
|
? Math.max(0, Math.floor(attachPayload.replayedFrom))
|
||||||
|
: null;
|
||||||
|
const currentOffset = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(attachPayload.currentOffset)
|
||||||
|
);
|
||||||
|
const shouldResetForReplayBootstrap =
|
||||||
|
typeof resumeOffset !== "number" &&
|
||||||
|
typeof replayedFromOffset === "number" &&
|
||||||
|
replayedFromOffset < currentOffset;
|
||||||
|
if (shouldResetForReplayBootstrap) {
|
||||||
|
this.options.onReset?.({ terminalId: input.terminalId });
|
||||||
|
}
|
||||||
|
const startExpectedOffset =
|
||||||
|
replayedFromOffset ??
|
||||||
|
(typeof resumeOffset === "number"
|
||||||
|
? Math.max(0, Math.floor(resumeOffset))
|
||||||
|
: currentOffset);
|
||||||
|
const catchUpEndOffset = currentOffset;
|
||||||
|
|
||||||
|
const activeStream: TerminalStreamControllerActiveStream = {
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId,
|
||||||
|
decoder,
|
||||||
|
nextExpectedOffset: startExpectedOffset,
|
||||||
|
catchUpEndOffset,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
};
|
||||||
|
this.activeStream = activeStream;
|
||||||
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
||||||
this.handleChunk({
|
this.handleChunk({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
@@ -316,21 +352,22 @@ export class TerminalStreamController {
|
|||||||
decoder,
|
decoder,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (this.activeStream === activeStream) {
|
||||||
this.activeStream = {
|
activeStream.unsubscribe = unsubscribe;
|
||||||
terminalId: input.terminalId,
|
} else {
|
||||||
streamId,
|
unsubscribe();
|
||||||
decoder,
|
}
|
||||||
unsubscribe,
|
|
||||||
};
|
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "stream-controller",
|
scope: "stream-controller",
|
||||||
event: "attach:success",
|
event: "attach:success",
|
||||||
details: {
|
details: {
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId,
|
streamId,
|
||||||
|
replayedFrom: attachPayload.replayedFrom ?? null,
|
||||||
|
requestedResumeOffset: resumeOffset ?? null,
|
||||||
currentOffset: attachPayload.currentOffset,
|
currentOffset: attachPayload.currentOffset,
|
||||||
reset: attachPayload.reset,
|
reset: attachPayload.reset,
|
||||||
|
bootstrapReset: shouldResetForReplayBootstrap,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
@@ -390,9 +427,54 @@ export class TerminalStreamController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chunkOffset = Number.isFinite(input.chunk.offset)
|
||||||
|
? Math.max(0, Math.floor(input.chunk.offset))
|
||||||
|
: 0;
|
||||||
|
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
|
||||||
|
? Math.max(0, Math.floor(input.chunk.endOffset))
|
||||||
|
: chunkOffset;
|
||||||
|
if (chunkEndOffset < chunkOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedOffset = activeStream.nextExpectedOffset;
|
||||||
|
if (typeof expectedOffset === "number") {
|
||||||
|
if (chunkEndOffset <= expectedOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunkOffset !== expectedOffset) {
|
||||||
|
const catchUpEndOffset = activeStream.catchUpEndOffset;
|
||||||
|
const canSkipReplayGap =
|
||||||
|
chunkOffset > expectedOffset &&
|
||||||
|
typeof catchUpEndOffset === "number" &&
|
||||||
|
expectedOffset < catchUpEndOffset &&
|
||||||
|
chunkOffset <= catchUpEndOffset;
|
||||||
|
|
||||||
|
if (canSkipReplayGap) {
|
||||||
|
activeStream.nextExpectedOffset = chunkOffset;
|
||||||
|
} else {
|
||||||
|
this.recoverFromStreamGap({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: input.streamId,
|
||||||
|
expectedOffset,
|
||||||
|
observedOffset: chunkOffset,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof activeStream.catchUpEndOffset === "number" &&
|
||||||
|
chunkEndOffset >= activeStream.catchUpEndOffset
|
||||||
|
) {
|
||||||
|
activeStream.catchUpEndOffset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStream.nextExpectedOffset = chunkEndOffset;
|
||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
offset: input.chunk.endOffset,
|
offset: chunkEndOffset,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,7 +488,9 @@ export class TerminalStreamController {
|
|||||||
details: {
|
details: {
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId: input.streamId,
|
streamId: input.streamId,
|
||||||
endOffset: input.chunk.endOffset,
|
offset: chunkOffset,
|
||||||
|
endOffset: chunkEndOffset,
|
||||||
|
replay: Boolean(input.chunk.replay),
|
||||||
byteLength: input.chunk.data.byteLength,
|
byteLength: input.chunk.data.byteLength,
|
||||||
textLength: text.length,
|
textLength: text.length,
|
||||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||||
@@ -419,6 +503,46 @@ export class TerminalStreamController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recoverFromStreamGap(input: {
|
||||||
|
terminalId: string;
|
||||||
|
streamId: number;
|
||||||
|
expectedOffset: number;
|
||||||
|
observedOffset: number;
|
||||||
|
}): void {
|
||||||
|
const activeStream = this.activeStream;
|
||||||
|
if (!activeStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedTerminalId !== input.terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTerminalResumeOffset({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
offset: input.expectedOffset,
|
||||||
|
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.attachGeneration += 1;
|
||||||
|
const generation = this.attachGeneration;
|
||||||
|
|
||||||
|
void this.detachActiveStream({ shouldDetach: true });
|
||||||
|
this.updateStatus({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: null,
|
||||||
|
isAttaching: true,
|
||||||
|
error:
|
||||||
|
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||||
|
});
|
||||||
|
void this.attachTerminal({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
||||||
const activeStream = this.activeStream;
|
const activeStream = this.activeStream;
|
||||||
if (!activeStream) {
|
if (!activeStream) {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { shouldClearAgentAttentionOnView } from "./agent-attention";
|
||||||
|
|
||||||
|
describe("shouldClearAgentAttentionOnView", () => {
|
||||||
|
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "agent-1",
|
||||||
|
focusedAgentId: "agent-1",
|
||||||
|
isConnected: true,
|
||||||
|
requiresAttention: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the app is disconnected", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "agent-1",
|
||||||
|
focusedAgentId: "agent-1",
|
||||||
|
isConnected: false,
|
||||||
|
requiresAttention: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the agent is not focused", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "agent-1",
|
||||||
|
focusedAgentId: "agent-2",
|
||||||
|
isConnected: true,
|
||||||
|
requiresAttention: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when attention is already clear", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "agent-1",
|
||||||
|
focusedAgentId: "agent-1",
|
||||||
|
isConnected: true,
|
||||||
|
requiresAttention: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty agent ids", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "",
|
||||||
|
focusedAgentId: "agent-1",
|
||||||
|
isConnected: true,
|
||||||
|
requiresAttention: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
interface ShouldClearAgentAttentionOnViewInput {
|
||||||
|
agentId: string | null | undefined;
|
||||||
|
focusedAgentId: string | null | undefined;
|
||||||
|
isConnected: boolean;
|
||||||
|
requiresAttention: boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearAgentAttentionOnView(
|
||||||
|
input: ShouldClearAgentAttentionOnViewInput
|
||||||
|
): boolean {
|
||||||
|
const agentId = input.agentId?.trim();
|
||||||
|
if (!agentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!input.isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!input.requiresAttention) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.focusedAgentId === agentId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||||
|
|
||||||
|
describe("collectAgentWorkingDirectorySuggestions", () => {
|
||||||
|
it("deduplicates by cwd and sorts by most recent timestamp", () => {
|
||||||
|
const results = collectAgentWorkingDirectorySuggestions([
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/project-alpha",
|
||||||
|
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/project-beta",
|
||||||
|
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/project-alpha",
|
||||||
|
lastActivityAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
"/Users/me/project-alpha",
|
||||||
|
"/Users/me/project-beta",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes Paseo-owned worktree paths", () => {
|
||||||
|
const results = collectAgentWorkingDirectorySuggestions([
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/repo/.paseo/worktrees/feature-a",
|
||||||
|
createdAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/repo",
|
||||||
|
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b",
|
||||||
|
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual(["/Users/me/repo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty cwd values", () => {
|
||||||
|
const results = collectAgentWorkingDirectorySuggestions([
|
||||||
|
{ cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") },
|
||||||
|
{ cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") },
|
||||||
|
{ cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") },
|
||||||
|
{
|
||||||
|
cwd: "/Users/me/project",
|
||||||
|
createdAt: new Date("2026-02-09T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual(["/Users/me/project"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface AgentWorkingDirectorySource {
|
||||||
|
cwd?: string | null;
|
||||||
|
createdAt?: Date | null;
|
||||||
|
lastActivityAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
|
||||||
|
|
||||||
|
export function collectAgentWorkingDirectorySuggestions(
|
||||||
|
sources: Iterable<AgentWorkingDirectorySource>
|
||||||
|
): string[] {
|
||||||
|
const lastSeenByPath = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const cwd = source.cwd?.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isPaseoOwnedWorktreePath(cwd)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt);
|
||||||
|
const previous = lastSeenByPath.get(cwd);
|
||||||
|
if (previous === undefined || timestamp > previous) {
|
||||||
|
lastSeenByPath.set(cwd, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(lastSeenByPath.entries())
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDiff = right[1] - left[1];
|
||||||
|
if (timeDiff !== 0) {
|
||||||
|
return timeDiff;
|
||||||
|
}
|
||||||
|
return left[0].localeCompare(right[0]);
|
||||||
|
})
|
||||||
|
.map(([cwd]) => cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaseoOwnedWorktreePath(cwd: string): boolean {
|
||||||
|
return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEpochMs(date: Date | null | undefined): number {
|
||||||
|
if (!(date instanceof Date)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const value = date.getTime();
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type MockPlatform = "web" | "ios" | "android";
|
||||||
|
|
||||||
|
type AlertButton = {
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||||
|
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
|
||||||
|
alertMock: ReturnType<typeof vi.fn>;
|
||||||
|
}> {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const alertMock = vi.fn();
|
||||||
|
vi.doMock("react-native", () => ({
|
||||||
|
Alert: {
|
||||||
|
alert: alertMock,
|
||||||
|
},
|
||||||
|
Platform: { OS: platform },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const module = await import("./confirm-dialog");
|
||||||
|
return { confirmDialog: module.confirmDialog, alertMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDialogGlobals(): void {
|
||||||
|
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
delete (globalThis as { confirm?: unknown }).confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("confirmDialog", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock("react-native");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
clearDialogGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Tauri dialog.ask on web when available", async () => {
|
||||||
|
const askMock = vi.fn(async () => true);
|
||||||
|
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||||
|
dialog: { ask: askMock },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(alertMock).not.toHaveBeenCalled();
|
||||||
|
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
|
||||||
|
title: "Restart host",
|
||||||
|
okLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
kind: "warning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||||
|
const browserConfirm = vi.fn(() => true);
|
||||||
|
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
|
||||||
|
|
||||||
|
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(browserConfirm).toHaveBeenCalledWith(
|
||||||
|
"Restart host\n\nThis will restart the daemon."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on web when no confirm backend exists", async () => {
|
||||||
|
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses native Alert on iOS/Android", async () => {
|
||||||
|
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
|
||||||
|
alertMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_title: string,
|
||||||
|
_message: string,
|
||||||
|
buttons?: AlertButton[]
|
||||||
|
) => {
|
||||||
|
const confirmButton = buttons?.[1];
|
||||||
|
confirmButton?.onPress?.();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(alertMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Alert, Platform } from "react-native";
|
||||||
|
|
||||||
|
export interface ConfirmDialogInput {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDialogAskOptions {
|
||||||
|
title?: string;
|
||||||
|
okLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
kind?: "info" | "warning" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDialogApi {
|
||||||
|
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriCoreApi {
|
||||||
|
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriGlobalApi {
|
||||||
|
dialog?: TauriDialogApi;
|
||||||
|
core?: TauriCoreApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmButtonConfig {
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
|
||||||
|
return {
|
||||||
|
confirmLabel: input.confirmLabel ?? "Confirm",
|
||||||
|
cancelLabel: input.cancelLabel ?? "Cancel",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||||
|
const labels = resolveButtonLabels(input);
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
Alert.alert(
|
||||||
|
input.title,
|
||||||
|
input.message,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: labels.cancelLabel,
|
||||||
|
style: "cancel",
|
||||||
|
onPress: () => resolve(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: labels.confirmLabel,
|
||||||
|
style: input.destructive ? "destructive" : "default",
|
||||||
|
onPress: () => resolve(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cancelable: true,
|
||||||
|
onDismiss: () => resolve(false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTauriApi(): TauriGlobalApi | null {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||||
|
return tauriApi ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||||
|
const labels = resolveButtonLabels(input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: input.title,
|
||||||
|
okLabel: labels.confirmLabel,
|
||||||
|
cancelLabel: labels.cancelLabel,
|
||||||
|
kind: input.destructive ? "warning" : "info",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||||
|
const tauriApi = getTauriApi();
|
||||||
|
if (!tauriApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = buildTauriAskOptions(input);
|
||||||
|
const tauriAsk = tauriApi.dialog?.ask;
|
||||||
|
|
||||||
|
if (typeof tauriAsk === "function") {
|
||||||
|
try {
|
||||||
|
return Boolean(await tauriAsk(input.message, options));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriInvoke = tauriApi.core?.invoke;
|
||||||
|
if (typeof tauriInvoke === "function") {
|
||||||
|
try {
|
||||||
|
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||||
|
message: input.message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return result === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
|
||||||
|
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
|
||||||
|
if (typeof browserConfirm !== "function") {
|
||||||
|
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptMessage = `${input.title}\n\n${input.message}`;
|
||||||
|
return browserConfirm(promptMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return showNativeConfirmDialog(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriResult = await showTauriConfirmDialog(input);
|
||||||
|
if (tauriResult !== null) {
|
||||||
|
return tauriResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return showWebConfirmDialog(input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { getIsTauri } from "@/constants/layout";
|
||||||
|
|
||||||
|
interface TauriWindowWithOpener {
|
||||||
|
__TAURI__?: {
|
||||||
|
opener?: {
|
||||||
|
openUrl?: (url: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openExternalUrl(url: string): Promise<void> {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && getIsTauri()) {
|
||||||
|
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||||
|
if (typeof opener === "function") {
|
||||||
|
await opener(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Linking.openURL(url);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
|||||||
import { parser as htmlParser } from "@lezer/html";
|
import { parser as htmlParser } from "@lezer/html";
|
||||||
import { parser as pythonParser } from "@lezer/python";
|
import { parser as pythonParser } from "@lezer/python";
|
||||||
import { parser as markdownParser } from "@lezer/markdown";
|
import { parser as markdownParser } from "@lezer/markdown";
|
||||||
|
import { parser as elixirParser } from "lezer-elixir";
|
||||||
import type { Parser } from "@lezer/common";
|
import type { Parser } from "@lezer/common";
|
||||||
|
|
||||||
// Map file extensions to parsers
|
// Map file extensions to parsers
|
||||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
|||||||
htm: htmlParser,
|
htm: htmlParser,
|
||||||
// Python
|
// Python
|
||||||
py: pythonParser,
|
py: pythonParser,
|
||||||
|
// Elixir
|
||||||
|
ex: elixirParser,
|
||||||
|
exs: elixirParser,
|
||||||
// Markdown
|
// Markdown
|
||||||
md: markdownParser,
|
md: markdownParser,
|
||||||
mdx: markdownParser,
|
mdx: markdownParser,
|
||||||
|
|||||||
@@ -109,7 +109,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||||
|
|
||||||
let unlisten: (() => void) | undefined;
|
let disposed = false;
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
let didCleanup = false;
|
||||||
|
|
||||||
|
function runCleanup() {
|
||||||
|
if (!cleanup || didCleanup) return;
|
||||||
|
didCleanup = true;
|
||||||
|
try {
|
||||||
|
void Promise.resolve(cleanup()).catch((error) => {
|
||||||
|
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const win = await getTauriWindow();
|
const win = await getTauriWindow();
|
||||||
@@ -117,19 +131,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
|||||||
|
|
||||||
// Check initial fullscreen state
|
// Check initial fullscreen state
|
||||||
const fullscreen = await win.isFullscreen();
|
const fullscreen = await win.isFullscreen();
|
||||||
|
if (disposed) return;
|
||||||
setIsFullscreen(fullscreen);
|
setIsFullscreen(fullscreen);
|
||||||
|
|
||||||
// Listen for resize events which include fullscreen changes
|
// Listen for resize events which include fullscreen changes
|
||||||
unlisten = await win.onResized(async () => {
|
const unlisten = await win.onResized(async () => {
|
||||||
|
if (disposed) return;
|
||||||
const fs = await win.isFullscreen();
|
const fs = await win.isFullscreen();
|
||||||
|
if (disposed) return;
|
||||||
setIsFullscreen(fs);
|
setIsFullscreen(fs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cleanup = unlisten;
|
||||||
|
if (disposed) {
|
||||||
|
runCleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setup();
|
void setup();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten?.();
|
disposed = true;
|
||||||
|
runCleanup();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { upsertTerminalListEntry } from "./terminal-list";
|
||||||
|
|
||||||
|
describe("terminal-list", () => {
|
||||||
|
it("adds a created terminal when the list is empty", () => {
|
||||||
|
const result = upsertTerminalListEntry({
|
||||||
|
terminals: [],
|
||||||
|
terminal: {
|
||||||
|
id: "term-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a created terminal when the id does not already exist", () => {
|
||||||
|
const result = upsertTerminalListEntry({
|
||||||
|
terminals: [{ id: "term-1", name: "Terminal 1" }],
|
||||||
|
terminal: {
|
||||||
|
id: "term-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: "term-1", name: "Terminal 1" },
|
||||||
|
{ id: "term-2", name: "Terminal 2" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the existing terminal entry when ids match", () => {
|
||||||
|
const result = upsertTerminalListEntry({
|
||||||
|
terminals: [
|
||||||
|
{ id: "term-1", name: "Terminal 1" },
|
||||||
|
{ id: "term-2", name: "Old Name" },
|
||||||
|
],
|
||||||
|
terminal: {
|
||||||
|
id: "term-2",
|
||||||
|
name: "Renamed Terminal",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: "term-1", name: "Terminal 1" },
|
||||||
|
{ id: "term-2", name: "Renamed Terminal" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
CreateTerminalResponse,
|
||||||
|
ListTerminalsResponse,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
|
||||||
|
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
|
||||||
|
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||||
|
|
||||||
|
function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry {
|
||||||
|
return {
|
||||||
|
id: input.terminal.id,
|
||||||
|
name: input.terminal.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertTerminalListEntry(input: {
|
||||||
|
terminals: TerminalListEntry[];
|
||||||
|
terminal: CreatedTerminal;
|
||||||
|
}): TerminalListEntry[] {
|
||||||
|
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
|
||||||
|
const existingIndex = input.terminals.findIndex(
|
||||||
|
(terminal) => terminal.id === createdTerminal.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
return [...input.terminals, createdTerminal];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTerminals = [...input.terminals];
|
||||||
|
nextTerminals[existingIndex] = createdTerminal;
|
||||||
|
return nextTerminals;
|
||||||
|
}
|
||||||
@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds display model from worktree setup detail", () => {
|
||||||
|
const display = buildToolCallDisplayModel({
|
||||||
|
name: "paseo_worktree_setup",
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||||
|
branchName: "feature-branch",
|
||||||
|
log: "==> [1/1] Running: npm install\n",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
command: "npm install",
|
||||||
|
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||||
|
status: "running",
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(display).toEqual({
|
||||||
|
displayName: "Worktree Setup",
|
||||||
|
summary: "feature-branch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not derive command summary from unknown raw detail", () => {
|
it("does not derive command summary from unknown raw detail", () => {
|
||||||
const display = buildToolCallDisplayModel({
|
const display = buildToolCallDisplayModel({
|
||||||
name: "exec_command",
|
name: "exec_command",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
|||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
write: Pencil,
|
write: Pencil,
|
||||||
search: Search,
|
search: Search,
|
||||||
|
worktree_setup: SquareTerminal,
|
||||||
unknown: Wrench,
|
unknown: Wrench,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions";
|
||||||
|
|
||||||
|
describe("buildWorkingDirectorySuggestions", () => {
|
||||||
|
it("returns de-duplicated recommendations when query is empty", () => {
|
||||||
|
const results = buildWorkingDirectorySuggestions({
|
||||||
|
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"],
|
||||||
|
serverPaths: ["/Users/me/projects/playground"],
|
||||||
|
query: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes matching recommended directories before server matches", () => {
|
||||||
|
const results = buildWorkingDirectorySuggestions({
|
||||||
|
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||||
|
serverPaths: [
|
||||||
|
"/Users/me/projects/playground",
|
||||||
|
"/Users/me/projects/paseo",
|
||||||
|
"/Users/me/projects/planbook",
|
||||||
|
],
|
||||||
|
query: "pla",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
"/Users/me/projects/playground",
|
||||||
|
"/Users/me/projects/planbook",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts matching recommended items first when they also match query", () => {
|
||||||
|
const results = buildWorkingDirectorySuggestions({
|
||||||
|
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
|
||||||
|
serverPaths: [
|
||||||
|
"/Users/me/projects/planbook",
|
||||||
|
"/Users/me/projects/playground",
|
||||||
|
],
|
||||||
|
query: "pla",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
"/Users/me/projects/playground",
|
||||||
|
"/Users/me/projects/planbook",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats '~' as an active query and includes server suggestions", () => {
|
||||||
|
const results = buildWorkingDirectorySuggestions({
|
||||||
|
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||||
|
serverPaths: [
|
||||||
|
"/Users/me/documents",
|
||||||
|
"/Users/me/projects",
|
||||||
|
],
|
||||||
|
query: "~",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
"/Users/me/projects/paseo",
|
||||||
|
"/Users/me/documents",
|
||||||
|
"/Users/me/projects",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export interface BuildWorkingDirectorySuggestionsInput {
|
||||||
|
recommendedPaths: string[];
|
||||||
|
serverPaths: string[];
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkingDirectorySuggestions(
|
||||||
|
input: BuildWorkingDirectorySuggestionsInput
|
||||||
|
): string[] {
|
||||||
|
const rawQuery = input.query.trim();
|
||||||
|
const recommended = uniquePaths(input.recommendedPaths);
|
||||||
|
if (!rawQuery) {
|
||||||
|
return recommended;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = normalizeQuery(rawQuery);
|
||||||
|
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||||
|
|
||||||
|
const recommendedMatches = shouldFilterByQuery
|
||||||
|
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
|
||||||
|
: recommended;
|
||||||
|
const seen = new Set(recommendedMatches);
|
||||||
|
const ordered = [...recommendedMatches];
|
||||||
|
|
||||||
|
for (const entry of uniquePaths(input.serverPaths)) {
|
||||||
|
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ordered.push(entry);
|
||||||
|
seen.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePaths(paths: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const ordered: string[] = [];
|
||||||
|
for (const pathEntry of paths) {
|
||||||
|
const trimmed = pathEntry.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(trimmed);
|
||||||
|
ordered.push(trimmed);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuery(query: string): string {
|
||||||
|
let normalized = query.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("~")) {
|
||||||
|
normalized = normalized.slice(1);
|
||||||
|
}
|
||||||
|
normalized = normalized.replace(/^\/+/, "").toLowerCase();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatchesQuery(candidatePath: string, query: string): boolean {
|
||||||
|
const lowerPath = candidatePath.toLowerCase();
|
||||||
|
if (lowerPath.includes(query)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const segments = lowerPath.split("/");
|
||||||
|
return (segments[segments.length - 1] ?? "").includes(query);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.4",
|
"@getpaseo/relay": "0.1.13",
|
||||||
"@getpaseo/server": "0.1.4",
|
"@getpaseo/server": "0.1.13",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
import { createAgentCommand } from './commands/agent/index.js'
|
import { createAgentCommand } from './commands/agent/index.js'
|
||||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||||
import { createPermitCommand } from './commands/permit/index.js'
|
import { createPermitCommand } from './commands/permit/index.js'
|
||||||
@@ -20,7 +21,9 @@ import { runUpdateCommand } from './commands/agent/update.js'
|
|||||||
import { withOutput } from './output/index.js'
|
import { withOutput } from './output/index.js'
|
||||||
import { onboardCommand } from './commands/onboard.js'
|
import { onboardCommand } from './commands/onboard.js'
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
const require = createRequire(import.meta.url)
|
||||||
|
const packageJson = require('../package.json') as { version?: string }
|
||||||
|
const VERSION = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'
|
||||||
|
|
||||||
// Helper function to collect multiple option values into an array
|
// Helper function to collect multiple option values into an array
|
||||||
function collectMultiple(value: string, previous: string[]): string[] {
|
function collectMultiple(value: string, previous: string[]): string[] {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { Command } from 'commander'
|
import type { Command } from 'commander'
|
||||||
import type { AgentSnapshotPayload } from '@getpaseo/server'
|
import {
|
||||||
|
getStructuredAgentResponse,
|
||||||
|
StructuredAgentResponseError,
|
||||||
|
type AgentSnapshotPayload,
|
||||||
|
} from '@getpaseo/server'
|
||||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
@@ -105,98 +109,55 @@ function loadOutputSchema(value: string): Record<string, unknown> {
|
|||||||
return parsed as Record<string, unknown>
|
return parsed as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFirstJsonObject(text: string): string | null {
|
class StructuredRunStatusError extends Error {
|
||||||
const source = text.trim()
|
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
|
||||||
if (!source) {
|
|
||||||
return null
|
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'StructuredRunStatusError'
|
||||||
|
this.kind = kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
|
||||||
|
|
||||||
|
export interface StructuredResponseTimelineClient {
|
||||||
|
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveStructuredResponseMessage(options: {
|
||||||
|
client: StructuredResponseTimelineClient
|
||||||
|
agentId: string
|
||||||
|
lastMessage: string | null
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const direct = options.lastMessage?.trim()
|
||||||
|
if (direct) {
|
||||||
|
return direct
|
||||||
}
|
}
|
||||||
|
|
||||||
const startIndexes: number[] = []
|
try {
|
||||||
for (let i = 0; i < source.length; i += 1) {
|
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||||
if (source[i] === '{') {
|
direction: 'tail',
|
||||||
startIndexes.push(i)
|
projection: 'projected',
|
||||||
}
|
limit: 200,
|
||||||
}
|
})
|
||||||
|
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||||
for (const start of startIndexes) {
|
const entry = timeline.entries[index]
|
||||||
let depth = 0
|
if (!entry || entry.item.type !== 'assistant_message') {
|
||||||
let inString = false
|
|
||||||
let escaped = false
|
|
||||||
|
|
||||||
for (let i = start; i < source.length; i += 1) {
|
|
||||||
const ch = source[i]!
|
|
||||||
|
|
||||||
if (inString) {
|
|
||||||
if (escaped) {
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === '\\') {
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === '"') {
|
|
||||||
inString = false
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const text = entry.item.text.trim()
|
||||||
if (ch === '"') {
|
if (text.length > 0) {
|
||||||
inString = true
|
return text
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ch === '{') {
|
|
||||||
depth += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === '}') {
|
|
||||||
depth -= 1
|
|
||||||
if (depth === 0) {
|
|
||||||
const candidate = source.slice(start, i + 1).trim()
|
|
||||||
try {
|
|
||||||
JSON.parse(candidate)
|
|
||||||
return candidate
|
|
||||||
} catch {
|
|
||||||
// Keep scanning.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave empty; caller will surface a consistent structured-output failure message.
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
|
|
||||||
const trimmed = lastMessage.trim()
|
|
||||||
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
|
||||||
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
|
|
||||||
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(jsonText)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: 'Agent response is not valid JSON',
|
|
||||||
details: message,
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: 'Agent response JSON must be an object',
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
||||||
return {
|
return {
|
||||||
...agentRunSchema,
|
...agentRunSchema,
|
||||||
@@ -318,6 +279,107 @@ export async function runRunCommand(
|
|||||||
labels['ui'] = 'true'
|
labels['ui'] = 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outputSchema) {
|
||||||
|
let structuredAgent: AgentSnapshotPayload | null = null
|
||||||
|
|
||||||
|
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||||
|
if (!structuredAgent) {
|
||||||
|
structuredAgent = await client.createAgent({
|
||||||
|
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||||
|
cwd,
|
||||||
|
title: options.name,
|
||||||
|
modeId: options.mode,
|
||||||
|
model: options.model,
|
||||||
|
initialPrompt: structuredPrompt,
|
||||||
|
images,
|
||||||
|
git,
|
||||||
|
worktreeName: options.worktree,
|
||||||
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await client.sendMessage(structuredAgent.id, structuredPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await client.waitForFinish(structuredAgent.id, 10 * 60 * 1000)
|
||||||
|
if (state.status === 'timeout') {
|
||||||
|
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
|
||||||
|
}
|
||||||
|
if (state.status === 'permission') {
|
||||||
|
throw new StructuredRunStatusError(
|
||||||
|
'permission',
|
||||||
|
'Agent is waiting for permission before producing structured output'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.status === 'error') {
|
||||||
|
throw new StructuredRunStatusError(
|
||||||
|
'error',
|
||||||
|
state.error ?? 'Agent failed before producing structured output'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = await resolveStructuredResponseMessage({
|
||||||
|
client,
|
||||||
|
agentId: structuredAgent.id,
|
||||||
|
lastMessage: state.lastMessage,
|
||||||
|
})
|
||||||
|
if (!lastMessage) {
|
||||||
|
throw new StructuredRunStatusError(
|
||||||
|
'empty',
|
||||||
|
'Agent finished without a structured output message'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
let output: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
output = await getStructuredAgentResponse<Record<string, unknown>>({
|
||||||
|
caller: callStructuredTurn,
|
||||||
|
prompt,
|
||||||
|
schema: outputSchema,
|
||||||
|
schemaName: 'RunOutput',
|
||||||
|
maxRetries: 2,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StructuredRunStatusError) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'OUTPUT_SCHEMA_FAILED',
|
||||||
|
message: err.message,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (err instanceof StructuredAgentResponseError) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'OUTPUT_SCHEMA_FAILED',
|
||||||
|
message: 'Agent response did not match the required output schema',
|
||||||
|
details:
|
||||||
|
err.validationErrors.length > 0
|
||||||
|
? err.validationErrors.join('\n')
|
||||||
|
: err.lastResponse || 'No response',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!structuredAgent) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'OUTPUT_SCHEMA_FAILED',
|
||||||
|
message: 'Agent finished without a structured output message',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: toRunResult(structuredAgent, 'completed'),
|
||||||
|
schema: structuredRunSchema(output),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the agent
|
// Create the agent
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||||
@@ -326,59 +388,12 @@ export async function runRunCommand(
|
|||||||
modeId: options.mode,
|
modeId: options.mode,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
initialPrompt: prompt,
|
initialPrompt: prompt,
|
||||||
outputSchema,
|
|
||||||
images,
|
images,
|
||||||
git,
|
git,
|
||||||
worktreeName: options.worktree,
|
worktreeName: options.worktree,
|
||||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (outputSchema) {
|
|
||||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
|
||||||
|
|
||||||
if (state.status === 'timeout') {
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: 'Timed out waiting for structured output',
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status === 'permission') {
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: 'Agent is waiting for permission before producing structured output',
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status === 'error') {
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: state.error ?? 'Agent failed before producing structured output',
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMessage = state.lastMessage?.trim()
|
|
||||||
if (!lastMessage) {
|
|
||||||
const error: CommandError = {
|
|
||||||
code: 'OUTPUT_SCHEMA_FAILED',
|
|
||||||
message: 'Agent finished without a structured output message',
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = parseStructuredOutput(lastMessage)
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'single',
|
|
||||||
data: toRunResult(agent, 'completed'),
|
|
||||||
schema: structuredRunSchema(output),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||||
if (!options.detach) {
|
if (!options.detach) {
|
||||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
|
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@@ -99,6 +99,22 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||||
|
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
if (options.home) {
|
||||||
|
childEnv.PASEO_HOME = options.home
|
||||||
|
}
|
||||||
|
if (options.listen) {
|
||||||
|
childEnv.PASEO_LISTEN = options.listen
|
||||||
|
} else if (options.port) {
|
||||||
|
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||||
|
}
|
||||||
|
if (options.allowedHosts) {
|
||||||
|
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
||||||
|
}
|
||||||
|
return childEnv
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDaemonRunnerEntry(): string {
|
function resolveDaemonRunnerEntry(): string {
|
||||||
const serverExportPath = require.resolve('@getpaseo/server')
|
const serverExportPath = require.resolve('@getpaseo/server')
|
||||||
let currentDir = path.dirname(serverExportPath)
|
let currentDir = path.dirname(serverExportPath)
|
||||||
@@ -279,18 +295,7 @@ export async function startLocalDaemonDetached(
|
|||||||
throw new Error('Cannot use --listen and --port together')
|
throw new Error('Cannot use --listen and --port together')
|
||||||
}
|
}
|
||||||
|
|
||||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
const childEnv = buildChildEnv(options)
|
||||||
if (options.home) {
|
|
||||||
childEnv.PASEO_HOME = options.home
|
|
||||||
}
|
|
||||||
if (options.listen) {
|
|
||||||
childEnv.PASEO_LISTEN = options.listen
|
|
||||||
} else if (options.port) {
|
|
||||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
|
||||||
}
|
|
||||||
if (options.allowedHosts) {
|
|
||||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
|
||||||
}
|
|
||||||
|
|
||||||
const paseoHome = resolvePaseoHome(childEnv)
|
const paseoHome = resolvePaseoHome(childEnv)
|
||||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||||
@@ -356,6 +361,29 @@ export async function startLocalDaemonDetached(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||||
|
if (options.listen && options.port) {
|
||||||
|
throw new Error('Cannot use --listen and --port together')
|
||||||
|
}
|
||||||
|
|
||||||
|
const childEnv = buildChildEnv(options)
|
||||||
|
const daemonRunnerEntry = resolveDaemonRunnerEntry()
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||||
|
{
|
||||||
|
env: childEnv,
|
||||||
|
stdio: 'inherit',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.status ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopLocalDaemon(
|
export async function stopLocalDaemon(
|
||||||
options: StopLocalDaemonOptions = {}
|
options: StopLocalDaemonOptions = {}
|
||||||
): Promise<StopLocalDaemonResult> {
|
): Promise<StopLocalDaemonResult> {
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import {
|
import {
|
||||||
createPaseoDaemon,
|
startLocalDaemonForeground,
|
||||||
loadConfig,
|
|
||||||
resolvePaseoHome,
|
|
||||||
createRootLogger,
|
|
||||||
loadPersistedConfig,
|
|
||||||
} from '@getpaseo/server'
|
|
||||||
import type { CliConfigOverrides } from '@getpaseo/server'
|
|
||||||
import {
|
|
||||||
startLocalDaemonDetached,
|
startLocalDaemonDetached,
|
||||||
type DaemonStartOptions as StartOptions,
|
type DaemonStartOptions as StartOptions,
|
||||||
} from './local-daemon.js'
|
} from './local-daemon.js'
|
||||||
@@ -34,34 +27,6 @@ export function startCommand(): Command {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCliOverrides(options: StartOptions): CliConfigOverrides {
|
|
||||||
const cliOverrides: CliConfigOverrides = {}
|
|
||||||
|
|
||||||
if (options.listen) {
|
|
||||||
cliOverrides.listen = options.listen
|
|
||||||
} else if (options.port) {
|
|
||||||
cliOverrides.listen = `127.0.0.1:${options.port}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.relay === false) {
|
|
||||||
cliOverrides.relayEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.allowedHosts) {
|
|
||||||
const raw = options.allowedHosts.trim()
|
|
||||||
cliOverrides.allowedHosts =
|
|
||||||
raw.toLowerCase() === 'true'
|
|
||||||
? true
|
|
||||||
: raw.split(',').map(h => h.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.mcp === false) {
|
|
||||||
cliOverrides.mcpEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return cliOverrides
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runStart(options: StartOptions): Promise<void> {
|
export async function runStart(options: StartOptions): Promise<void> {
|
||||||
if (options.listen && options.port) {
|
if (options.listen && options.port) {
|
||||||
console.error(chalk.red('Cannot use --listen and --port together'))
|
console.error(chalk.red('Cannot use --listen and --port together'))
|
||||||
@@ -78,63 +43,9 @@ export async function runStart(options: StartOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.home) {
|
|
||||||
process.env.PASEO_HOME = options.home
|
|
||||||
}
|
|
||||||
|
|
||||||
let paseoHome: string
|
|
||||||
let logger: ReturnType<typeof createRootLogger>
|
|
||||||
let config: ReturnType<typeof loadConfig>
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
paseoHome = resolvePaseoHome()
|
const status = startLocalDaemonForeground(options)
|
||||||
const persistedConfig = loadPersistedConfig(paseoHome)
|
process.exit(status)
|
||||||
logger = createRootLogger(persistedConfig)
|
|
||||||
config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
|
||||||
} catch (err) {
|
|
||||||
exitWithError(getErrorMessage(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>>
|
|
||||||
try {
|
|
||||||
daemon = await createPaseoDaemon(config, logger)
|
|
||||||
} catch (err) {
|
|
||||||
const message = getErrorMessage(err)
|
|
||||||
exitWithError(`Failed to initialize daemon: ${message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let shuttingDown = false
|
|
||||||
const handleShutdown = async (signal: string) => {
|
|
||||||
if (shuttingDown) {
|
|
||||||
logger.info('Forcing exit...')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
shuttingDown = true
|
|
||||||
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
|
|
||||||
|
|
||||||
const forceExit = setTimeout(() => {
|
|
||||||
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
|
|
||||||
process.exit(1)
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await daemon.stop()
|
|
||||||
clearTimeout(forceExit)
|
|
||||||
logger.info('Server closed')
|
|
||||||
process.exit(0)
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(forceExit)
|
|
||||||
logger.error({ err }, 'Shutdown failed')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
|
|
||||||
process.on('SIGINT', () => handleShutdown('SIGINT'))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await daemon.start()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
const message = getErrorMessage(err)
|
||||||
exitWithError(`Failed to start daemon: ${message}`)
|
exitWithError(`Failed to start daemon: ${message}`)
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ interface OnboardOptions extends DaemonStartOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OnboardPersistedConfig = PersistedConfig & {
|
type OnboardPersistedConfig = PersistedConfig & {
|
||||||
providers?: PersistedConfig['providers'] & {
|
|
||||||
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
|
|
||||||
}
|
|
||||||
features?: PersistedConfig['features'] & {
|
features?: PersistedConfig['features'] & {
|
||||||
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
||||||
? T & { enabled?: boolean }
|
? T & { enabled?: boolean }
|
||||||
@@ -103,13 +100,6 @@ function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig):
|
|||||||
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
providers: {
|
|
||||||
...config.providers,
|
|
||||||
local: {
|
|
||||||
...config.providers?.local,
|
|
||||||
autoDownload: enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
features: {
|
features: {
|
||||||
...config.features,
|
...config.features,
|
||||||
dictation: {
|
dictation: {
|
||||||
@@ -366,7 +356,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
|||||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||||
|
|
||||||
const voiceStatus = voiceEnabled
|
const voiceStatus = voiceEnabled
|
||||||
? 'Voice features enabled. Local speech models will download in the background if missing.'
|
? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
|
||||||
: 'Voice features disabled. Local speech models will not be downloaded.'
|
: 'Voice features disabled. Local speech models will not be downloaded.'
|
||||||
log.message(voiceStatus)
|
log.message(voiceStatus)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
|||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(timeoutHandle)
|
clearTimeout(timeoutHandle)
|
||||||
}
|
}
|
||||||
client.subscribeAgentUpdates({ subscriptionId: `cli:${process.pid}` })
|
|
||||||
return client
|
return client
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Clear the timeout on error too
|
// Clear the timeout on error too
|
||||||
|
|||||||
@@ -46,14 +46,10 @@ try {
|
|||||||
dictation?: { enabled?: boolean }
|
dictation?: { enabled?: boolean }
|
||||||
voiceMode?: { enabled?: boolean }
|
voiceMode?: { enabled?: boolean }
|
||||||
}
|
}
|
||||||
providers?: {
|
|
||||||
local?: { autoDownload?: boolean }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
||||||
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
||||||
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
|
|
||||||
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
||||||
assert(
|
assert(
|
||||||
!daemonLog.includes('Ensuring local speech models'),
|
!daemonLog.includes('Ensuring local speech models'),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import {
|
||||||
|
resolveStructuredResponseMessage,
|
||||||
|
type StructuredResponseTimelineClient,
|
||||||
|
} from '../src/commands/agent/run.ts'
|
||||||
|
|
||||||
|
type TimelineEntry = {
|
||||||
|
item: {
|
||||||
|
type: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(options: {
|
||||||
|
entries?: TimelineEntry[]
|
||||||
|
throwOnFetch?: boolean
|
||||||
|
onFetch?: () => void
|
||||||
|
}): StructuredResponseTimelineClient {
|
||||||
|
return {
|
||||||
|
fetchAgentTimeline: async () => {
|
||||||
|
options.onFetch?.()
|
||||||
|
if (options.throwOnFetch) {
|
||||||
|
throw new Error('timeline unavailable')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entries: options.entries ?? [],
|
||||||
|
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Run Output Schema Helper Tests ===\n')
|
||||||
|
|
||||||
|
// Test 1: Direct lastMessage is returned without timeline fetch.
|
||||||
|
{
|
||||||
|
let fetchCount = 0
|
||||||
|
const client = createClient({ onFetch: () => fetchCount += 1 })
|
||||||
|
const result = await resolveStructuredResponseMessage({
|
||||||
|
client,
|
||||||
|
agentId: 'agent-1',
|
||||||
|
lastMessage: ' {"summary":"ok"} ',
|
||||||
|
})
|
||||||
|
assert.strictEqual(result, '{"summary":"ok"}')
|
||||||
|
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
|
||||||
|
console.log('✓ returns direct lastMessage when present')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Falls back to latest assistant message from timeline.
|
||||||
|
{
|
||||||
|
const client = createClient({
|
||||||
|
entries: [
|
||||||
|
{ item: { type: 'user_message', text: 'prompt' } },
|
||||||
|
{ item: { type: 'assistant_message', text: '' } },
|
||||||
|
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const result = await resolveStructuredResponseMessage({
|
||||||
|
client,
|
||||||
|
agentId: 'agent-2',
|
||||||
|
lastMessage: null,
|
||||||
|
})
|
||||||
|
assert.strictEqual(result, '{"summary":"from timeline"}')
|
||||||
|
console.log('✓ falls back to latest assistant timeline entry')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Returns null when timeline has no assistant messages.
|
||||||
|
{
|
||||||
|
const client = createClient({
|
||||||
|
entries: [
|
||||||
|
{ item: { type: 'user_message', text: 'prompt' } },
|
||||||
|
{ item: { type: 'reasoning', text: 'thinking' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const result = await resolveStructuredResponseMessage({
|
||||||
|
client,
|
||||||
|
agentId: 'agent-3',
|
||||||
|
lastMessage: null,
|
||||||
|
})
|
||||||
|
assert.strictEqual(result, null)
|
||||||
|
console.log('✓ returns null when no assistant messages exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Returns null if timeline fetch fails.
|
||||||
|
{
|
||||||
|
const client = createClient({ throwOnFetch: true })
|
||||||
|
const result = await resolveStructuredResponseMessage({
|
||||||
|
client,
|
||||||
|
agentId: 'agent-4',
|
||||||
|
lastMessage: null,
|
||||||
|
})
|
||||||
|
assert.strictEqual(result, null)
|
||||||
|
console.log('✓ returns null when timeline fetch throws')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== All helper tests passed ===')
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||||
|
|
||||||
|
interface E2EContext extends TestDaemonContext {
|
||||||
|
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = JSON.stringify({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['summary'],
|
||||||
|
additionalProperties: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const impossibleSchema = JSON.stringify({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['summary'],
|
||||||
|
allOf: [
|
||||||
|
{ properties: { summary: { type: 'string' } } },
|
||||||
|
{ properties: { summary: { type: 'number' } } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
let ctx: E2EContext
|
||||||
|
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
ctx = await createE2ETestContext({ timeout: 180000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
if (ctx) {
|
||||||
|
await ctx.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProviderCase(input: {
|
||||||
|
provider: 'claude' | 'codex' | 'opencode'
|
||||||
|
mode: string
|
||||||
|
model: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'--provider',
|
||||||
|
input.provider,
|
||||||
|
'--mode',
|
||||||
|
input.mode,
|
||||||
|
'--model',
|
||||||
|
input.model,
|
||||||
|
'--output-schema',
|
||||||
|
schema,
|
||||||
|
`Return valid JSON with a short summary for provider ${input.provider}.`,
|
||||||
|
],
|
||||||
|
{ timeout: 180000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.exitCode,
|
||||||
|
0,
|
||||||
|
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof parsed.summary,
|
||||||
|
'string',
|
||||||
|
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
|
||||||
|
)
|
||||||
|
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_all_providers_return_structured_output(): Promise<void> {
|
||||||
|
await runProviderCase({
|
||||||
|
provider: 'claude',
|
||||||
|
mode: 'bypassPermissions',
|
||||||
|
model: 'haiku',
|
||||||
|
})
|
||||||
|
await runProviderCase({
|
||||||
|
provider: 'codex',
|
||||||
|
mode: 'full-access',
|
||||||
|
model: 'gpt-5.3-codex',
|
||||||
|
})
|
||||||
|
await runProviderCase({
|
||||||
|
provider: 'opencode',
|
||||||
|
mode: 'default',
|
||||||
|
model: 'opencode/kimi-k2.5-free',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_schema_validation_is_enforced(): Promise<void> {
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'--provider',
|
||||||
|
'claude',
|
||||||
|
'--mode',
|
||||||
|
'bypassPermissions',
|
||||||
|
'--model',
|
||||||
|
'haiku',
|
||||||
|
'--output-schema',
|
||||||
|
impossibleSchema,
|
||||||
|
'Return exactly {"summary":"ok"} and nothing else.',
|
||||||
|
],
|
||||||
|
{ timeout: 180000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
|
||||||
|
const output = `${result.stdout}\n${result.stderr}`
|
||||||
|
assert(
|
||||||
|
output.includes('OUTPUT_SCHEMA_FAILED'),
|
||||||
|
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await setup()
|
||||||
|
await test_all_providers_return_structured_output()
|
||||||
|
await test_schema_validation_is_enforced()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Tauri wrapper)",
|
"description": "Paseo desktop app (Tauri wrapper)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+570
@@ -81,6 +81,137 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -173,6 +304,19 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -416,6 +560,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "concurrent-queue"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -660,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,6 +908,33 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -780,6 +962,43 @@ dependencies = [
|
|||||||
"typeid",
|
"typeid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -914,6 +1133,19 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-lite"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1281,6 +1513,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1573,6 +1811,25 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-docker"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-wsl"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||||
|
dependencies = [
|
||||||
|
"is-docker",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1735,6 +1992,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2165,12 +2428,34 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open"
|
||||||
|
version = "5.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"is-wsl",
|
||||||
|
"libc",
|
||||||
|
"pathdiff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2196,6 +2481,12 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -2228,10 +2519,18 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathdiff"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -2384,6 +2683,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -2416,6 +2726,20 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2793,6 +3117,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2861,6 +3209,19 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.36"
|
version = "0.23.36"
|
||||||
@@ -3205,6 +3566,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -3602,6 +3973,46 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 0.9.11+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -3624,6 +4035,28 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-opener"
|
||||||
|
version = "2.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
"open",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
"windows",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-websocket"
|
name = "tauri-plugin-websocket"
|
||||||
version = "2.4.2"
|
version = "2.4.2"
|
||||||
@@ -3745,6 +4178,19 @@ dependencies = [
|
|||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.11+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4055,9 +4501,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -4126,6 +4584,17 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5070,6 +5539,67 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"ordered-stream",
|
||||||
|
"rustix",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 0.7.14",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.4.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"winnow 0.7.14",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.33"
|
version = "0.8.33"
|
||||||
@@ -5155,3 +5685,43 @@ name = "zmij"
|
|||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"winnow 0.7.14",
|
||||||
|
"zvariant_derive",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.4.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.114",
|
||||||
|
"winnow 0.7.14",
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,5 +26,7 @@ serde_json = "1.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.9.5", features = [] }
|
tauri = { version = "2.9.5", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-badge-count",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
"dialog:default",
|
||||||
|
"opener:default",
|
||||||
"websocket:default"
|
"websocket:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.4",
|
"version": "0.1.13",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"types": "./dist/server/server/exports.d.ts",
|
"types": "./dist/server/server/exports.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
|
"dist/src",
|
||||||
"dist/scripts",
|
"dist/scripts",
|
||||||
"README.md",
|
"README.md",
|
||||||
".env.example",
|
".env.example",
|
||||||
@@ -52,10 +53,10 @@
|
|||||||
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
|
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
|
||||||
"@getpaseo/relay": "0.1.4",
|
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
|
"@getpaseo/relay": "0.1.13",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/css": "^1.3.0",
|
"@lezer/css": "^1.3.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
@@ -73,8 +74,9 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
@@ -91,13 +93,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"playwright": "^1.56.1",
|
|
||||||
"@types/express": "^4.17.20",
|
"@types/express": "^4.17.20",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
|
"playwright": "^1.56.1",
|
||||||
"tsx": "^4.6.0",
|
"tsx": "^4.6.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { runSupervisor } from "./supervisor.js";
|
import { runSupervisor } from "./supervisor.js";
|
||||||
|
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||||
|
|
||||||
|
type DaemonRunnerConfig = {
|
||||||
|
devMode: boolean;
|
||||||
|
workerArgs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseConfig(argv: string[]): DaemonRunnerConfig {
|
||||||
|
let devMode = false;
|
||||||
|
const workerArgs: string[] = [];
|
||||||
|
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg === "--dev") {
|
||||||
|
devMode = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
workerArgs.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { devMode, workerArgs };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWorkerEntry(): string {
|
function resolveWorkerEntry(): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -19,18 +40,32 @@ function resolveWorkerEntry(): string {
|
|||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkerExecArgv(): string[] {
|
function resolveDevWorkerEntry(): string {
|
||||||
const workerEntry = resolveWorkerEntry();
|
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||||
|
if (!existsSync(candidate)) {
|
||||||
|
throw new Error(`Dev worker entry not found: ${candidate}`);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkerExecArgv(workerEntry: string): string[] {
|
||||||
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = parseConfig(process.argv.slice(2));
|
||||||
|
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||||
|
|
||||||
|
applySherpaLoaderEnv(process.env);
|
||||||
|
|
||||||
runSupervisor({
|
runSupervisor({
|
||||||
name: "DaemonRunner",
|
name: "DaemonRunner",
|
||||||
startupMessage: "Starting daemon worker (IPC restart enabled)",
|
startupMessage: config.devMode
|
||||||
resolveWorkerEntry,
|
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
||||||
workerArgs: process.argv.slice(2),
|
: "Starting daemon worker (IPC restart enabled)",
|
||||||
|
resolveWorkerEntry: () => workerEntry,
|
||||||
|
workerArgs: config.workerArgs,
|
||||||
workerEnv: process.env,
|
workerEnv: process.env,
|
||||||
workerExecArgv: resolveWorkerExecArgv(),
|
workerExecArgv: resolveWorkerExecArgv(workerEntry),
|
||||||
restartOnCrash: false,
|
restartOnCrash: config.devMode,
|
||||||
shutdownReasons: ["cli_shutdown"],
|
shutdownReasons: ["cli_shutdown"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import dotenv from "dotenv";
|
||||||
import { runSupervisor } from "./supervisor.js";
|
import { runSupervisor } from "./supervisor.js";
|
||||||
|
|
||||||
|
dotenv.config({
|
||||||
|
path: fileURLToPath(new URL("../.env", import.meta.url)),
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
|
||||||
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||||
if (!existsSync(WORKER_ENTRY)) {
|
if (!existsSync(WORKER_ENTRY)) {
|
||||||
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
|
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
|
||||||
|
|||||||
@@ -42,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo
|
|||||||
const logger = createRootLogger({ level: "info", format: "pretty" });
|
const logger = createRootLogger({ level: "info", format: "pretty" });
|
||||||
|
|
||||||
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
|
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
|
||||||
await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger });
|
await ensureLocalSpeechModels({ modelsDir, modelIds, logger });
|
||||||
logger.info({ modelsDir, modelIds }, "Done downloading speech models");
|
logger.info({ modelsDir, modelIds }, "Done downloading speech models");
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ logger.info(
|
|||||||
await ensureLocalSpeechModels({
|
await ensureLocalSpeechModels({
|
||||||
modelsDir: options.modelsDir,
|
modelsDir: options.modelsDir,
|
||||||
modelIds: options.modelIds,
|
modelIds: options.modelIds,
|
||||||
autoDownload: true,
|
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ type CliOptions = {
|
|||||||
outPath?: string;
|
outPath?: string;
|
||||||
model: LocalSttModelId;
|
model: LocalSttModelId;
|
||||||
modelsDir: string;
|
modelsDir: string;
|
||||||
autoDownload: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function usage(): string {
|
function usage(): string {
|
||||||
return [
|
return [
|
||||||
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>] [--no-auto-download]",
|
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>]",
|
||||||
"",
|
"",
|
||||||
"Examples:",
|
"Examples:",
|
||||||
" npm run speech:transcribe:local -- ./sample.wav",
|
" npm run speech:transcribe:local -- ./sample.wav",
|
||||||
@@ -52,7 +51,6 @@ function parseArgs(argv: string[]): CliOptions {
|
|||||||
let outPath: string | undefined;
|
let outPath: string | undefined;
|
||||||
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
|
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
|
||||||
let modelsDir = defaultModelsDir;
|
let modelsDir = defaultModelsDir;
|
||||||
let autoDownload = true;
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
@@ -87,11 +85,6 @@ function parseArgs(argv: string[]): CliOptions {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg === "--no-auto-download") {
|
|
||||||
autoDownload = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg.startsWith("-")) {
|
if (arg.startsWith("-")) {
|
||||||
throw new Error(`Unknown option: ${arg}`);
|
throw new Error(`Unknown option: ${arg}`);
|
||||||
}
|
}
|
||||||
@@ -108,7 +101,6 @@ function parseArgs(argv: string[]): CliOptions {
|
|||||||
...(outPath ? { outPath } : {}),
|
...(outPath ? { outPath } : {}),
|
||||||
model,
|
model,
|
||||||
modelsDir,
|
modelsDir,
|
||||||
autoDownload,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +131,6 @@ async function main(): Promise<void> {
|
|||||||
providers,
|
providers,
|
||||||
local: {
|
local: {
|
||||||
modelsDir: options.modelsDir,
|
modelsDir: options.modelsDir,
|
||||||
autoDownload: options.autoDownload,
|
|
||||||
models: {
|
models: {
|
||||||
dictationStt: options.model,
|
dictationStt: options.model,
|
||||||
voiceStt: options.model,
|
voiceStt: options.model,
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ async function runVoiceRoundTrip(params: {
|
|||||||
}): Promise<RoundTripResult> {
|
}): Promise<RoundTripResult> {
|
||||||
const client = new DaemonClient({ url: `${params.daemonUrl}/ws` });
|
const client = new DaemonClient({ url: `${params.daemonUrl}/ws` });
|
||||||
await client.connect();
|
await client.connect();
|
||||||
client.subscribeAgentUpdates({ subscriptionId: `voice-e2e-${randomUUID()}` });
|
await client.fetchAgents({
|
||||||
|
subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` },
|
||||||
|
});
|
||||||
|
|
||||||
const mode = await client.setVoiceMode(true, params.voiceAgentId);
|
const mode = await client.setVoiceMode(true, params.voiceAgentId);
|
||||||
if (!mode.accepted) {
|
if (!mode.accepted) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
client.subscribeAgentUpdates({ subscriptionId: "voice-debug" });
|
await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } });
|
||||||
|
|
||||||
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-"));
|
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-"));
|
||||||
const voiceAgent = await client.createAgent({
|
const voiceAgent = await client.createAgent({
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ describe("TerminalStreamManager", () => {
|
|||||||
|
|
||||||
expect(seen).toEqual(["hello"]);
|
expect(seen).toEqual(["hello"]);
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
|
});
|
||||||
|
|
||||||
|
test("acks buffered chunks even while no subscriber is attached", () => {
|
||||||
|
const sendAck = vi.fn();
|
||||||
|
const manager = new TerminalStreamManager({ sendAck });
|
||||||
|
|
||||||
|
manager.receiveChunk({
|
||||||
|
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not ack when every handler throws", () => {
|
test("does not ack when every handler throws", () => {
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ export class TerminalStreamManager {
|
|||||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
const streamHandlers = this.handlers.get(chunk.streamId);
|
||||||
if (!streamHandlers || streamHandlers.size === 0) {
|
if (!streamHandlers || streamHandlers.size === 0) {
|
||||||
this.bufferChunk({ chunk });
|
this.bufferChunk({ chunk });
|
||||||
// Ack buffered chunks immediately to keep the daemon-side stream window
|
|
||||||
// moving even before a UI subscriber is attached.
|
|
||||||
this.maybeAckChunk({
|
this.maybeAckChunk({
|
||||||
streamId: chunk.streamId,
|
streamId: chunk.streamId,
|
||||||
endOffset: chunk.endOffset,
|
endOffset: chunk.endOffset,
|
||||||
|
|||||||
@@ -347,6 +347,63 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requests directory suggestions via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.getDirectorySuggestions(
|
||||||
|
{ query: "proj", limit: 10 },
|
||||||
|
"req-directories"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "directory_suggestions_request";
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("directory_suggestions_request");
|
||||||
|
expect(request.message.query).toBe("proj");
|
||||||
|
expect(request.message.limit).toBe(10);
|
||||||
|
expect(request.message.requestId).toBe("req-directories");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "directory_suggestions_response",
|
||||||
|
payload: {
|
||||||
|
directories: ["/Users/test/projects/paseo"],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-directories",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
directories: ["/Users/test/projects/paseo"],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-directories",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("requests checkout merge from base via RPC", async () => {
|
test("requests checkout merge from base via RPC", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -478,6 +535,7 @@ describe("DaemonClient", () => {
|
|||||||
{ key: "created_at", direction: "desc" },
|
{ key: "created_at", direction: "desc" },
|
||||||
],
|
],
|
||||||
page: { limit: 25, cursor: "cursor-1" },
|
page: { limit: 25, cursor: "cursor-1" },
|
||||||
|
subscribe: { subscriptionId: "sub-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
expect(mock.sent).toHaveLength(1);
|
||||||
@@ -492,6 +550,7 @@ describe("DaemonClient", () => {
|
|||||||
direction: "asc" | "desc";
|
direction: "asc" | "desc";
|
||||||
}>;
|
}>;
|
||||||
page?: { limit: number; cursor?: string };
|
page?: { limit: number; cursor?: string };
|
||||||
|
subscribe?: { subscriptionId?: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
expect(request.message.type).toBe("fetch_agents_request");
|
expect(request.message.type).toBe("fetch_agents_request");
|
||||||
@@ -500,6 +559,7 @@ describe("DaemonClient", () => {
|
|||||||
{ key: "created_at", direction: "desc" },
|
{ key: "created_at", direction: "desc" },
|
||||||
]);
|
]);
|
||||||
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||||
|
expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" });
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -508,6 +568,7 @@ describe("DaemonClient", () => {
|
|||||||
type: "fetch_agents_response",
|
type: "fetch_agents_response",
|
||||||
payload: {
|
payload: {
|
||||||
requestId: request.message.requestId,
|
requestId: request.message.requestId,
|
||||||
|
subscriptionId: "sub-1",
|
||||||
entries: [],
|
entries: [],
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -521,6 +582,7 @@ describe("DaemonClient", () => {
|
|||||||
|
|
||||||
await expect(promise).resolves.toEqual({
|
await expect(promise).resolves.toEqual({
|
||||||
requestId: request.message.requestId,
|
requestId: request.message.requestId,
|
||||||
|
subscriptionId: "sub-1",
|
||||||
entries: [],
|
entries: [],
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -707,6 +769,139 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists commands with draft config via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.listCommands("__new_agent__", {
|
||||||
|
draftConfig: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "list_commands_request";
|
||||||
|
agentId: string;
|
||||||
|
draftConfig?: {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
};
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("list_commands_request");
|
||||||
|
expect(request.message.agentId).toBe("__new_agent__");
|
||||||
|
expect(request.message.draftConfig).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "__new_agent__",
|
||||||
|
commands: [
|
||||||
|
{ name: "help", description: "Show help", argumentHint: "" },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
agentId: "__new_agent__",
|
||||||
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||||
|
error: null,
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists commands with legacy requestId signature via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.listCommands("agent-1", "req-legacy");
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "list_commands_request";
|
||||||
|
agentId: string;
|
||||||
|
draftConfig?: unknown;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("list_commands_request");
|
||||||
|
expect(request.message.agentId).toBe("agent-1");
|
||||||
|
expect(request.message.requestId).toBe("req-legacy");
|
||||||
|
expect(request.message.draftConfig).toBeUndefined();
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
commands: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-legacy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
agentId: "agent-1",
|
||||||
|
commands: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-legacy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("auto-acks terminal stream chunks after delivery", async () => {
|
test("auto-acks terminal stream chunks after delivery", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -753,6 +948,48 @@ describe("DaemonClient", () => {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
client.onTerminalStreamData(11, (chunk) => {
|
||||||
|
seen.push(new TextDecoder().decode(chunk.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = new TextEncoder().encode("ls\r\n");
|
||||||
|
const frame = encodeBinaryMuxFrame({
|
||||||
|
channel: BinaryMuxChannel.Terminal,
|
||||||
|
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||||
|
streamId: 11,
|
||||||
|
offset: 0,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
||||||
|
|
||||||
|
mock.triggerMessage(frameAsString);
|
||||||
|
|
||||||
|
expect(seen).toEqual(["ls\r\n"]);
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
||||||
|
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
|
||||||
|
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
||||||
|
expect(ackFrame?.streamId).toBe(11);
|
||||||
|
expect(ackFrame?.offset).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
test("acks buffered terminal chunks when handler is attached", async () => {
|
test("acks buffered terminal chunks when handler is attached", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -1194,4 +1431,89 @@ describe("DaemonClient", () => {
|
|||||||
expect(received).toHaveLength(0);
|
expect(received).toHaveLength(0);
|
||||||
expect(logger.warn).toHaveBeenCalled();
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sends subscribe/unsubscribe terminals messages", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
client.subscribeTerminals({ cwd: "/tmp/project" });
|
||||||
|
client.unsubscribeTerminals({ cwd: "/tmp/project" });
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(2);
|
||||||
|
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "subscribe_terminals_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(mock.sent[1]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "unsubscribe_terminals_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatches terminals_changed events to typed listeners", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const received: Array<{ cwd: string; names: string[] }> = [];
|
||||||
|
const unsubscribe = client.on("terminals_changed", (message) => {
|
||||||
|
received.push({
|
||||||
|
cwd: message.payload.cwd,
|
||||||
|
names: message.payload.terminals.map((terminal) => terminal.name),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "terminals_changed",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
terminals: [
|
||||||
|
{
|
||||||
|
id: "term-1",
|
||||||
|
name: "Dev Server",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(received).toEqual([
|
||||||
|
{
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
names: ["Dev Server"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
ValidateBranchResponse,
|
ValidateBranchResponse,
|
||||||
BranchSuggestionsResponse,
|
BranchSuggestionsResponse,
|
||||||
|
DirectorySuggestionsResponse,
|
||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
@@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
|||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||||
|
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
@@ -207,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
|
|||||||
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
|
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
|
||||||
|
type ListCommandsDraftConfig = Pick<
|
||||||
|
AgentSessionConfig,
|
||||||
|
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
||||||
|
>;
|
||||||
|
type ListCommandsOptions = {
|
||||||
|
requestId?: string;
|
||||||
|
draftConfig?: ListCommandsDraftConfig;
|
||||||
|
};
|
||||||
type SetVoiceModePayload = Extract<
|
type SetVoiceModePayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "set_voice_mode_response" }
|
{ type: "set_voice_mode_response" }
|
||||||
@@ -348,14 +358,11 @@ export class DaemonClient {
|
|||||||
private connectReject: ((error: Error) => void) | null = null;
|
private connectReject: ((error: Error) => void) | null = null;
|
||||||
private lastErrorValue: string | null = null;
|
private lastErrorValue: string | null = null;
|
||||||
private connectionState: ConnectionState = { status: "idle" };
|
private connectionState: ConnectionState = { status: "idle" };
|
||||||
private agentUpdateSubscriptions = new Map<
|
|
||||||
string,
|
|
||||||
{ labels?: Record<string, string>; agentId?: string } | undefined
|
|
||||||
>();
|
|
||||||
private checkoutDiffSubscriptions = new Map<
|
private checkoutDiffSubscriptions = new Map<
|
||||||
string,
|
string,
|
||||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||||
>();
|
>();
|
||||||
|
private terminalDirectorySubscriptions = new Set<string>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private pendingSendQueue: PendingSend[] = [];
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
private relayClientId: string | null = null;
|
private relayClientId: string | null = null;
|
||||||
@@ -472,8 +479,8 @@ export class DaemonClient {
|
|||||||
this.lastErrorValue = null;
|
this.lastErrorValue = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" });
|
this.updateConnectionState({ status: "connected" });
|
||||||
this.resubscribeAgentUpdates();
|
|
||||||
this.resubscribeCheckoutDiffSubscriptions();
|
this.resubscribeCheckoutDiffSubscriptions();
|
||||||
|
this.resubscribeTerminalDirectorySubscriptions();
|
||||||
this.flushPendingSendQueue();
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
}),
|
}),
|
||||||
@@ -993,6 +1000,7 @@ export class DaemonClient {
|
|||||||
...(options?.filter ? { filter: options.filter } : {}),
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
...(options?.sort ? { sort: options.sort } : {}),
|
...(options?.sort ? { sort: options.sort } : {}),
|
||||||
...(options?.page ? { page: options.page } : {}),
|
...(options?.page ? { page: options.page } : {}),
|
||||||
|
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
||||||
});
|
});
|
||||||
return this.sendRequest({
|
return this.sendRequest({
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
@@ -1039,44 +1047,6 @@ export class DaemonClient {
|
|||||||
return payload.agent;
|
return payload.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeAgentUpdates(options?: {
|
|
||||||
subscriptionId?: string;
|
|
||||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
|
||||||
}): string {
|
|
||||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
|
||||||
this.agentUpdateSubscriptions.set(subscriptionId, options?.filter);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "subscribe_agent_updates",
|
|
||||||
subscriptionId,
|
|
||||||
...(options?.filter ? { filter: options.filter } : {}),
|
|
||||||
});
|
|
||||||
this.sendSessionMessage(message);
|
|
||||||
return subscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribeAgentUpdates(subscriptionId: string): void {
|
|
||||||
this.agentUpdateSubscriptions.delete(subscriptionId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "unsubscribe_agent_updates",
|
|
||||||
subscriptionId,
|
|
||||||
});
|
|
||||||
this.sendSessionMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resubscribeAgentUpdates(): void {
|
|
||||||
if (this.agentUpdateSubscriptions.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [subscriptionId, filter] of this.agentUpdateSubscriptions) {
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "subscribe_agent_updates",
|
|
||||||
subscriptionId,
|
|
||||||
...(filter ? { filter } : {}),
|
|
||||||
});
|
|
||||||
this.sendSessionMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resubscribeCheckoutDiffSubscriptions(): void {
|
private resubscribeCheckoutDiffSubscriptions(): void {
|
||||||
if (this.checkoutDiffSubscriptions.size === 0) {
|
if (this.checkoutDiffSubscriptions.size === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1093,6 +1063,18 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resubscribeTerminalDirectorySubscriptions(): void {
|
||||||
|
if (this.terminalDirectorySubscriptions.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const cwd of this.terminalDirectorySubscriptions) {
|
||||||
|
this.sendSessionMessage({
|
||||||
|
type: "subscribe_terminals_request",
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent Lifecycle
|
// Agent Lifecycle
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2068,6 +2050,22 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDirectorySuggestions(
|
||||||
|
options: { query: string; limit?: number },
|
||||||
|
requestId?: string
|
||||||
|
): Promise<DirectorySuggestionsPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "directory_suggestions_request",
|
||||||
|
query: options.query,
|
||||||
|
limit: options.limit,
|
||||||
|
},
|
||||||
|
responseType: "directory_suggestions_response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// File Explorer
|
// File Explorer
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2181,15 +2179,30 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||||
async listCommands(
|
async listCommands(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
requestId?: string
|
options?: ListCommandsOptions
|
||||||
|
): Promise<ListCommandsPayload>;
|
||||||
|
async listCommands(
|
||||||
|
agentId: string,
|
||||||
|
requestIdOrOptions?: string | ListCommandsOptions
|
||||||
): Promise<ListCommandsPayload> {
|
): Promise<ListCommandsPayload> {
|
||||||
|
const requestId =
|
||||||
|
typeof requestIdOrOptions === "string"
|
||||||
|
? requestIdOrOptions
|
||||||
|
: requestIdOrOptions?.requestId;
|
||||||
|
const draftConfig =
|
||||||
|
typeof requestIdOrOptions === "string"
|
||||||
|
? undefined
|
||||||
|
: requestIdOrOptions?.draftConfig;
|
||||||
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
message: {
|
message: {
|
||||||
type: "list_commands_request",
|
type: "list_commands_request",
|
||||||
agentId,
|
agentId,
|
||||||
|
...(draftConfig ? { draftConfig } : {}),
|
||||||
},
|
},
|
||||||
responseType: "list_commands_response",
|
responseType: "list_commands_response",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
@@ -2314,6 +2327,28 @@ export class DaemonClient {
|
|||||||
// Terminals
|
// Terminals
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
subscribeTerminals(input: { cwd: string }): void {
|
||||||
|
this.terminalDirectorySubscriptions.add(input.cwd);
|
||||||
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendSessionMessage({
|
||||||
|
type: "subscribe_terminals_request",
|
||||||
|
cwd: input.cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeTerminals(input: { cwd: string }): void {
|
||||||
|
this.terminalDirectorySubscriptions.delete(input.cwd);
|
||||||
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendSessionMessage({
|
||||||
|
type: "unsubscribe_terminals_request",
|
||||||
|
cwd: input.cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async listTerminals(
|
async listTerminals(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
|||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
import {
|
||||||
|
appendTimelineItemIfAgentKnown,
|
||||||
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
|
} from "./timeline-append.js";
|
||||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
@@ -357,6 +360,12 @@ export async function createAgentManagementMcpServer(
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
emitLiveTimelineItem: (item) =>
|
||||||
|
emitLiveTimelineItemIfAgentKnown({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,7 +383,9 @@ export async function createAgentManagementMcpServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId: snapshot.id },
|
{ err: error, agentId: snapshot.id },
|
||||||
@@ -599,7 +610,9 @@ export async function createAgentManagementMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(agentId, prompt);
|
agentManager.recordUserMessage(agentId, prompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -351,6 +351,150 @@ describe("AgentManager", () => {
|
|||||||
expect(afterHydrate).toEqual(beforeReload);
|
expect(afterHydrate).toEqual(beforeReload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
|
class DelayedPersistenceSession extends TestAgentSession {
|
||||||
|
private persistenceReady = false;
|
||||||
|
private interrupted = false;
|
||||||
|
private releaseGate: (() => void) | null = null;
|
||||||
|
private readonly gate = new Promise<void>((resolve) => {
|
||||||
|
this.releaseGate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
private readonly stableSessionId: string,
|
||||||
|
initiallyReady = false
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
this.persistenceReady = initiallyReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
yield { type: "turn_started", provider: this.provider };
|
||||||
|
this.persistenceReady = true;
|
||||||
|
yield {
|
||||||
|
type: "thread_started",
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.stableSessionId,
|
||||||
|
};
|
||||||
|
await this.gate;
|
||||||
|
if (this.interrupted) {
|
||||||
|
yield { type: "turn_canceled", provider: this.provider, reason: "Interrupted" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield { type: "turn_completed", provider: this.provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.persistenceReady ? this.stableSessionId : null,
|
||||||
|
model: null,
|
||||||
|
modeId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describePersistence() {
|
||||||
|
if (!this.persistenceReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.stableSessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
this.interrupted = true;
|
||||||
|
this.releaseGate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.interrupted = true;
|
||||||
|
this.releaseGate?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DelayedPersistenceClient implements AgentClient {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
createSessionCalls = 0;
|
||||||
|
resumeSessionCalls = 0;
|
||||||
|
private nextSessionNumber = 1;
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
const sessionId = `delayed-session-${this.nextSessionNumber++}`;
|
||||||
|
this.createSessionCalls += 1;
|
||||||
|
return new DelayedPersistenceSession(config, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
handle: AgentPersistenceHandle,
|
||||||
|
overrides?: Partial<AgentSessionConfig>
|
||||||
|
): Promise<AgentSession> {
|
||||||
|
this.resumeSessionCalls += 1;
|
||||||
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||||
|
const merged: AgentSessionConfig = {
|
||||||
|
...metadata,
|
||||||
|
...overrides,
|
||||||
|
provider: "codex",
|
||||||
|
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
||||||
|
};
|
||||||
|
return new DelayedPersistenceSession(merged, handle.sessionId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new DelayedPersistenceClient();
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { codex: client },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000114",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
expect(snapshot.persistence).toBeNull();
|
||||||
|
|
||||||
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||||
|
const first = await stream.next();
|
||||||
|
expect(first.done).toBe(false);
|
||||||
|
expect(first.value?.type).toBe("turn_started");
|
||||||
|
const second = await stream.next();
|
||||||
|
expect(second.done).toBe(false);
|
||||||
|
expect(second.value?.type).toBe("thread_started");
|
||||||
|
|
||||||
|
const active = manager.getAgent(snapshot.id);
|
||||||
|
expect(active?.lifecycle).toBe("running");
|
||||||
|
expect(active?.persistence?.sessionId).toBe("delayed-session-1");
|
||||||
|
|
||||||
|
const reloaded = await manager.reloadAgentSession(snapshot.id, {
|
||||||
|
systemPrompt: "voice mode on",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.createSessionCalls).toBe(1);
|
||||||
|
expect(client.resumeSessionCalls).toBe(1);
|
||||||
|
expect(reloaded.persistence?.sessionId).toBe("delayed-session-1");
|
||||||
|
|
||||||
|
// Drain stream after cancellation to ensure clean shutdown.
|
||||||
|
while (true) {
|
||||||
|
const next = await stream.next();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => {
|
test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -405,6 +549,66 @@ describe("AgentManager", () => {
|
|||||||
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
|
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits live timeline updates without recording canonical timeline rows", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamEvents: Array<{
|
||||||
|
seq?: number;
|
||||||
|
epoch?: string;
|
||||||
|
eventType?: string;
|
||||||
|
itemType?: string;
|
||||||
|
}> = [];
|
||||||
|
manager.subscribe(
|
||||||
|
(event) => {
|
||||||
|
if (event.type !== "agent_stream") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamEvents.push({
|
||||||
|
seq: event.seq,
|
||||||
|
epoch: event.epoch,
|
||||||
|
eventType: event.event.type,
|
||||||
|
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ agentId: snapshot.id, replayState: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.emitLiveTimelineItem(snapshot.id, {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "live-only update",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamEvents).toHaveLength(1);
|
||||||
|
expect(streamEvents[0]).toMatchObject({
|
||||||
|
eventType: "timeline",
|
||||||
|
itemType: "assistant_message",
|
||||||
|
});
|
||||||
|
expect(streamEvents[0]?.seq).toBeUndefined();
|
||||||
|
expect(streamEvents[0]?.epoch).toBeUndefined();
|
||||||
|
|
||||||
|
expect(manager.getTimeline(snapshot.id)).toEqual([]);
|
||||||
|
const fetched = manager.fetchTimeline(snapshot.id, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
expect(fetched.rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => {
|
test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -588,6 +792,74 @@ describe("AgentManager", () => {
|
|||||||
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
||||||
|
try {
|
||||||
|
manager.recordUserMessage(snapshot.id, "hello");
|
||||||
|
const afterMessage = manager.getAgent(snapshot.id);
|
||||||
|
expect(afterMessage).toBeDefined();
|
||||||
|
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
||||||
|
|
||||||
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||||
|
const afterRunStart = manager.getAgent(snapshot.id);
|
||||||
|
expect(afterRunStart).toBeDefined();
|
||||||
|
expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt);
|
||||||
|
|
||||||
|
await stream.return(undefined);
|
||||||
|
} finally {
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecycleUpdates: string[] = [];
|
||||||
|
manager.subscribe((event) => {
|
||||||
|
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lifecycleUpdates.push(event.agent.lifecycle);
|
||||||
|
});
|
||||||
|
lifecycleUpdates.length = 0;
|
||||||
|
|
||||||
|
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
|
||||||
|
|
||||||
|
expect(lifecycleUpdates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentSlashCommand,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
@@ -307,6 +308,15 @@ export class AgentManager {
|
|||||||
this.onAgentAttention = callback;
|
this.onAgentAttention = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private touchUpdatedAt(agent: ManagedAgent): Date {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const previousMs = agent.updatedAt.getTime();
|
||||||
|
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
||||||
|
const next = new Date(nextMs);
|
||||||
|
agent.updatedAt = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
||||||
const targetAgentId =
|
const targetAgentId =
|
||||||
options?.agentId == null
|
options?.agentId == null
|
||||||
@@ -428,6 +438,36 @@ export class AgentManager {
|
|||||||
return Promise.all(checks);
|
return Promise.all(checks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||||
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
const available = await client.isAvailable();
|
||||||
|
if (!available) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await client.createSession(normalizedConfig);
|
||||||
|
try {
|
||||||
|
if (!session.listCommands) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${normalizedConfig.provider}' does not support listing commands`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await session.listCommands();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await session.close();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, provider: normalizedConfig.provider },
|
||||||
|
"Failed to close draft command listing session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAgent(id: string): ManagedAgent | null {
|
getAgent(id: string): ManagedAgent | null {
|
||||||
const agent = this.agents.get(id);
|
const agent = this.agents.get(id);
|
||||||
return agent ? { ...agent } : null;
|
return agent ? { ...agent } : null;
|
||||||
@@ -657,7 +697,11 @@ export class AgentManager {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
overrides?: Partial<AgentSessionConfig>
|
overrides?: Partial<AgentSessionConfig>
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const existing = this.requireAgent(agentId);
|
let existing = this.requireAgent(agentId);
|
||||||
|
if (existing.lifecycle === "running" || existing.pendingRun) {
|
||||||
|
await this.cancelAgentRun(agentId);
|
||||||
|
existing = this.requireAgent(agentId);
|
||||||
|
}
|
||||||
const timelineState = this.ensureTimelineState(existing);
|
const timelineState = this.ensureTimelineState(existing);
|
||||||
const preservedTimeline = [...existing.timeline];
|
const preservedTimeline = [...existing.timeline];
|
||||||
const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row }));
|
const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row }));
|
||||||
@@ -847,7 +891,7 @@ export class AgentManager {
|
|||||||
recordUserMessage(
|
recordUserMessage(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
text: string,
|
text: string,
|
||||||
options?: { messageId?: string }
|
options?: { messageId?: string; emitState?: boolean }
|
||||||
): void {
|
): void {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const item: AgentTimelineItem = {
|
const item: AgentTimelineItem = {
|
||||||
@@ -855,8 +899,8 @@ export class AgentManager {
|
|||||||
text,
|
text,
|
||||||
messageId: options?.messageId,
|
messageId: options?.messageId,
|
||||||
};
|
};
|
||||||
agent.updatedAt = new Date();
|
const updatedAt = this.touchUpdatedAt(agent);
|
||||||
agent.lastUserMessageAt = agent.updatedAt;
|
agent.lastUserMessageAt = updatedAt;
|
||||||
const row = this.recordTimeline(agent, item);
|
const row = this.recordTimeline(agent, item);
|
||||||
this.dispatchStream(agentId, {
|
this.dispatchStream(agentId, {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -866,12 +910,14 @@ export class AgentManager {
|
|||||||
seq: row.seq,
|
seq: row.seq,
|
||||||
epoch: this.ensureTimelineState(agent).epoch,
|
epoch: this.ensureTimelineState(agent).epoch,
|
||||||
});
|
});
|
||||||
this.emitState(agent);
|
if (options?.emitState !== false) {
|
||||||
|
this.emitState(agent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
const row = this.recordTimeline(agent, item);
|
const row = this.recordTimeline(agent, item);
|
||||||
this.dispatchStream(agentId, {
|
this.dispatchStream(agentId, {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -884,6 +930,19 @@ export class AgentManager {
|
|||||||
await this.persistSnapshot(agent);
|
await this.persistSnapshot(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async emitLiveTimelineItem(
|
||||||
|
agentId: string,
|
||||||
|
item: AgentTimelineItem
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = this.requireAgent(agentId);
|
||||||
|
this.touchUpdatedAt(agent);
|
||||||
|
this.dispatchStream(agentId, {
|
||||||
|
type: "timeline",
|
||||||
|
item,
|
||||||
|
provider: agent.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
streamAgent(
|
streamAgent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
@@ -955,7 +1014,7 @@ export class AgentManager {
|
|||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
// Bump updatedAt when lifecycle changes so downstream consumers can
|
// Bump updatedAt when lifecycle changes so downstream consumers can
|
||||||
// deterministically order idle->running transitions.
|
// deterministically order idle->running transitions.
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
self.emitState(agent);
|
self.emitState(agent);
|
||||||
|
|
||||||
return streamForwarder;
|
return streamForwarder;
|
||||||
@@ -1527,7 +1586,7 @@ export class AgentManager {
|
|||||||
): void {
|
): void {
|
||||||
// Only update timestamp for live events, not history replay
|
// Only update timestamp for live events, not history replay
|
||||||
if (!options?.fromHistory) {
|
if (!options?.fromHistory) {
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
let timelineRow: AgentTimelineRow | null = null;
|
let timelineRow: AgentTimelineRow | null = null;
|
||||||
@@ -1537,9 +1596,13 @@ export class AgentManager {
|
|||||||
// Update persistence with the new session ID from the provider.
|
// Update persistence with the new session ID from the provider.
|
||||||
// persistence.sessionId is the single source of truth for session identity.
|
// persistence.sessionId is the single source of truth for session identity.
|
||||||
{
|
{
|
||||||
|
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||||
const handle = agent.session.describePersistence();
|
const handle = agent.session.describePersistence();
|
||||||
if (handle) {
|
if (handle) {
|
||||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||||
|
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||||
|
this.emitState(agent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { Logger } from "pino";
|
|||||||
|
|
||||||
import type { AgentManager } from "./agent-manager.js";
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||||
|
StructuredAgentFallbackError,
|
||||||
StructuredAgentResponseError,
|
StructuredAgentResponseError,
|
||||||
generateStructuredAgentResponse,
|
generateStructuredAgentResponseWithFallback,
|
||||||
} from "./agent-response-loop.js";
|
} from "./agent-response-loop.js";
|
||||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,12 +16,8 @@ import {
|
|||||||
type CheckoutStatusResult,
|
type CheckoutStatusResult,
|
||||||
} from "../../utils/checkout-git.js";
|
} from "../../utils/checkout-git.js";
|
||||||
|
|
||||||
const AUTO_GEN_PROVIDER = "codex" as const;
|
|
||||||
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
|
|
||||||
const AUTO_GEN_REASONING_EFFORT = "low";
|
|
||||||
|
|
||||||
export type AgentMetadataGeneratorDeps = {
|
export type AgentMetadataGeneratorDeps = {
|
||||||
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
|
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||||
};
|
};
|
||||||
@@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
|
const generator =
|
||||||
|
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||||
|
generateStructuredAgentResponseWithFallback;
|
||||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||||
|
|
||||||
@@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
try {
|
try {
|
||||||
result = await generator({
|
result = await generator({
|
||||||
manager: options.agentManager,
|
manager: options.agentManager,
|
||||||
agentConfig: {
|
cwd: options.cwd,
|
||||||
provider: AUTO_GEN_PROVIDER,
|
|
||||||
model: AUTO_GEN_MODEL,
|
|
||||||
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
|
|
||||||
cwd: options.cwd,
|
|
||||||
title: "Agent metadata generator",
|
|
||||||
internal: true,
|
|
||||||
},
|
|
||||||
prompt: buildPrompt(needs),
|
prompt: buildPrompt(needs),
|
||||||
schema,
|
schema,
|
||||||
schemaName: "AgentMetadata",
|
schemaName: "AgentMetadata",
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
|
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||||
|
agentConfigOverrides: {
|
||||||
|
title: "Agent metadata generator",
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof StructuredAgentResponseError) {
|
if (
|
||||||
|
error instanceof StructuredAgentResponseError ||
|
||||||
|
error instanceof StructuredAgentFallbackError
|
||||||
|
) {
|
||||||
options.logger.warn(
|
options.logger.warn(
|
||||||
{ err: error, agentId: options.agentId },
|
{ err: error, agentId: options.agentId },
|
||||||
"Structured metadata generation failed"
|
"Structured metadata generation failed"
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getStructuredAgentResponse,
|
getStructuredAgentResponse,
|
||||||
|
generateStructuredAgentResponseWithFallback,
|
||||||
|
StructuredAgentFallbackError,
|
||||||
StructuredAgentResponseError,
|
StructuredAgentResponseError,
|
||||||
type AgentCaller,
|
type AgentCaller,
|
||||||
} from "./agent-response-loop.js";
|
} from "./agent-response-loop.js";
|
||||||
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
|
|
||||||
function createScriptedCaller(responses: string[]) {
|
function createScriptedCaller(responses: string[]) {
|
||||||
const prompts: string[] = [];
|
const prompts: string[] = [];
|
||||||
@@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => {
|
|||||||
expect(result).toEqual({ value: 42 });
|
expect(result).toEqual({ value: 42 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generateStructuredAgentResponseWithFallback", () => {
|
||||||
|
const schema = z.object({ summary: z.string() });
|
||||||
|
|
||||||
|
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
|
||||||
|
return {
|
||||||
|
listProviderAvailability: async () => availability,
|
||||||
|
} as unknown as AgentManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("uses the first available provider in the waterfall", async () => {
|
||||||
|
const calls: Array<{ provider: string; model?: string }> = [];
|
||||||
|
const manager = createManager([
|
||||||
|
{ provider: "claude", available: true, error: null },
|
||||||
|
{ provider: "codex", available: true, error: null },
|
||||||
|
{ provider: "opencode", available: true, error: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await generateStructuredAgentResponseWithFallback({
|
||||||
|
manager,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
prompt: "Return JSON",
|
||||||
|
schema,
|
||||||
|
providers: [
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
],
|
||||||
|
runner: async (options) => {
|
||||||
|
calls.push({
|
||||||
|
provider: options.agentConfig.provider,
|
||||||
|
model: options.agentConfig.model ?? undefined,
|
||||||
|
});
|
||||||
|
return { summary: "ok" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ summary: "ok" });
|
||||||
|
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unavailable providers and uses the next available one", async () => {
|
||||||
|
const calls: Array<{ provider: string; model?: string }> = [];
|
||||||
|
const manager = createManager([
|
||||||
|
{ provider: "claude", available: false, error: "missing auth" },
|
||||||
|
{ provider: "codex", available: true, error: null },
|
||||||
|
{ provider: "opencode", available: true, error: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await generateStructuredAgentResponseWithFallback({
|
||||||
|
manager,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
prompt: "Return JSON",
|
||||||
|
schema,
|
||||||
|
providers: [
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
],
|
||||||
|
runner: async (options) => {
|
||||||
|
calls.push({
|
||||||
|
provider: options.agentConfig.provider,
|
||||||
|
model: options.agentConfig.model ?? undefined,
|
||||||
|
});
|
||||||
|
return { summary: "ok" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ summary: "ok" });
|
||||||
|
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when an available provider fails", async () => {
|
||||||
|
const calls: Array<{ provider: string; model?: string }> = [];
|
||||||
|
const manager = createManager([
|
||||||
|
{ provider: "claude", available: true, error: null },
|
||||||
|
{ provider: "codex", available: true, error: null },
|
||||||
|
{ provider: "opencode", available: true, error: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await generateStructuredAgentResponseWithFallback({
|
||||||
|
manager,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
prompt: "Return JSON",
|
||||||
|
schema,
|
||||||
|
providers: [
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
],
|
||||||
|
runner: async (options) => {
|
||||||
|
calls.push({
|
||||||
|
provider: options.agentConfig.provider,
|
||||||
|
model: options.agentConfig.model ?? undefined,
|
||||||
|
});
|
||||||
|
if (options.agentConfig.provider === "claude") {
|
||||||
|
throw new Error("claude failed");
|
||||||
|
}
|
||||||
|
return { summary: "ok" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ summary: "ok" });
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||||
|
const manager = createManager([
|
||||||
|
{ provider: "claude", available: false, error: "missing auth" },
|
||||||
|
{ provider: "codex", available: true, error: null },
|
||||||
|
{ provider: "opencode", available: false, error: "not installed" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
generateStructuredAgentResponseWithFallback({
|
||||||
|
manager,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
prompt: "Return JSON",
|
||||||
|
schema,
|
||||||
|
providers: [
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||||
|
],
|
||||||
|
runner: async () => {
|
||||||
|
throw new Error("failed");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||||
import type { AgentManager } from "./agent-manager.js";
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||||
|
|
||||||
@@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StructuredGenerationProvider = {
|
||||||
|
provider: AgentProvider;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StructuredGenerationAttempt = {
|
||||||
|
provider: AgentProvider;
|
||||||
|
model: string | null;
|
||||||
|
available: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class StructuredAgentFallbackError extends Error {
|
||||||
|
readonly attempts: StructuredGenerationAttempt[];
|
||||||
|
|
||||||
|
constructor(attempts: StructuredGenerationAttempt[]) {
|
||||||
|
const summary = attempts
|
||||||
|
.map((attempt) => {
|
||||||
|
const modelSuffix = attempt.model ? ` (${attempt.model})` : "";
|
||||||
|
if (!attempt.available) {
|
||||||
|
return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||||
|
}
|
||||||
|
return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
super(
|
||||||
|
summary.length > 0
|
||||||
|
? `Structured generation failed for all providers: ${summary}`
|
||||||
|
: "Structured generation failed for all providers"
|
||||||
|
);
|
||||||
|
this.name = "StructuredAgentFallbackError";
|
||||||
|
this.attempts = attempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface StructuredAgentResponseOptions<T> {
|
export interface StructuredAgentResponseOptions<T> {
|
||||||
caller: AgentCaller;
|
caller: AgentCaller;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions<T> {
|
|||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||||
|
manager: AgentManager;
|
||||||
|
cwd: string;
|
||||||
|
prompt: string;
|
||||||
|
schema: z.ZodType<T> | JsonSchema;
|
||||||
|
providers: readonly StructuredGenerationProvider[];
|
||||||
|
agentConfigOverrides?: Omit<
|
||||||
|
AgentSessionConfig,
|
||||||
|
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||||
|
>;
|
||||||
|
maxRetries?: number;
|
||||||
|
schemaName?: string;
|
||||||
|
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||||
|
{ provider: "claude", model: "haiku" },
|
||||||
|
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||||
|
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface SchemaValidator<T> {
|
interface SchemaValidator<T> {
|
||||||
jsonSchema: JsonSchema;
|
jsonSchema: JsonSchema;
|
||||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
|
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
|
||||||
@@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStructuredAgentResponseWithFallback<T>(
|
||||||
|
options: StructuredAgentGenerationWithFallbackOptions<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
manager,
|
||||||
|
cwd,
|
||||||
|
prompt,
|
||||||
|
schema,
|
||||||
|
providers,
|
||||||
|
agentConfigOverrides,
|
||||||
|
maxRetries,
|
||||||
|
schemaName,
|
||||||
|
runner,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new StructuredAgentFallbackError([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runStructured =
|
||||||
|
runner ??
|
||||||
|
((input: StructuredAgentGenerationOptions<T>) =>
|
||||||
|
generateStructuredAgentResponse<T>(input));
|
||||||
|
const availability = await manager.listProviderAvailability();
|
||||||
|
const availabilityByProvider = new Map(
|
||||||
|
availability.map((entry) => [entry.provider, entry])
|
||||||
|
);
|
||||||
|
const attempts: StructuredGenerationAttempt[] = [];
|
||||||
|
|
||||||
|
for (const candidate of providers) {
|
||||||
|
const availabilityEntry = availabilityByProvider.get(candidate.provider);
|
||||||
|
if (availabilityEntry && !availabilityEntry.available) {
|
||||||
|
attempts.push({
|
||||||
|
provider: candidate.provider,
|
||||||
|
model: candidate.model ?? null,
|
||||||
|
available: false,
|
||||||
|
error: availabilityEntry.error ?? null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runStructured({
|
||||||
|
manager,
|
||||||
|
prompt,
|
||||||
|
schema,
|
||||||
|
maxRetries,
|
||||||
|
schemaName,
|
||||||
|
agentConfig: {
|
||||||
|
...agentConfigOverrides,
|
||||||
|
provider: candidate.provider,
|
||||||
|
cwd,
|
||||||
|
...(candidate.model ? { model: candidate.model } : {}),
|
||||||
|
...(candidate.thinkingOptionId
|
||||||
|
? { thinkingOptionId: candidate.thinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
attempts.push({
|
||||||
|
provider: candidate.provider,
|
||||||
|
model: candidate.model ?? null,
|
||||||
|
available: true,
|
||||||
|
error: errorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new StructuredAgentFallbackError(attempts);
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,21 @@ export type ToolCallDetail =
|
|||||||
type: "search";
|
type: "search";
|
||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "worktree_setup";
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
log: string;
|
||||||
|
commands: Array<{
|
||||||
|
index: number;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
status: "running" | "completed" | "failed";
|
||||||
|
exitCode: number | null;
|
||||||
|
durationMs?: number;
|
||||||
|
}>;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "unknown";
|
type: "unknown";
|
||||||
input: unknown | null;
|
input: unknown | null;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
|||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
import {
|
||||||
|
appendTimelineItemIfAgentKnown,
|
||||||
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
|
} from "./timeline-append.js";
|
||||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
@@ -563,6 +566,12 @@ export async function createAgentMcpServer(
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
emitLiveTimelineItem: (item) =>
|
||||||
|
emitLiveTimelineItemIfAgentKnown({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -579,7 +588,9 @@ export async function createAgentMcpServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId: snapshot.id },
|
{ err: error, agentId: snapshot.id },
|
||||||
@@ -806,7 +817,9 @@ export async function createAgentMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(agentId, prompt);
|
agentManager.recordUserMessage(agentId, prompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user